diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba1722be..63fd99d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,6 +141,11 @@ jobs: OpenWorkGraph is local-first, open-source context infrastructure for how human and AI-agent work actually happens. + ### First useful reconstruction + The local dashboard now has an evidence-driven first-value layer designed to become useful during the first real work session. It watches only the existing privacy-hardened local evidence surfaces and, once enough activity exists, offers a deterministic **Your last few minutes** reconstruction. This is a presentation layer over canonical evidence, not a new inference or capture pipeline. + + The activation layer adds no sensor, OS/browser permission, AI permission, retention permission, Gateway sharing, MCP tool, screenshot capture, filesystem watcher, prompt/response capture or content telemetry. It never auto-enables AI access or saved history. Empty first-run placeholders are suppressed until their underlying features have useful data, while Evidence, History, Agents, Connect, Organization and Export remain available unchanged. + ### Local-first remains the default A normal OpenWorkGraph install captures to local SQLite, provides local dashboard/export/API/MCP access, and requires no OpenWorkGraph account or OpenWorkGraph-hosted evidence store. Capture continues locally if an optional customer-controlled Gateway or network is unavailable. @@ -156,7 +161,7 @@ jobs: The dashboard now separates giving an AI access to OpenWorkGraph context from observing an agent's own execution. It provides reviewable setup material for Claude Code lifecycle hooks, Codex trace export, OpenAI Agents tracing and generic OpenTelemetry/custom structural adapters. OpenWorkGraph does not silently edit third-party configuration files. An integration is shown as active only when telemetry actually observed by the local evidence store supports that status. ### Custom harnesses - Arbitrary self-built or third-party agent harnesses can now connect in either or both directions. Python and Node/TypeScript helpers, OTLP/HTTP JSON and raw structural HTTP can send privacy-safe execution telemetry through the dedicated write-only agent credential. Any MCP-capable harness can separately read only the OpenWorkGraph context the user has authorized. The setup flow keeps telemetry write permission and context/history read permission explicitly separate. + Arbitrary self-built or third-party agent harnesses can connect in either or both directions. Python and Node/TypeScript helpers, OTLP/HTTP JSON and raw structural HTTP can send privacy-safe execution telemetry through the dedicated write-only agent credential. Any MCP-capable harness can separately read only the OpenWorkGraph context the user has authorized. The setup flow keeps telemetry write permission and context/history read permission explicitly separate. The standalone helpers do not accept or serialize prompt text, model responses, tool arguments/results, returned values, exception text or hidden reasoning. OpenWorkGraph observer failures remain fail-open for the agent. This release publishes **OpenWorkGraph-Agent-Python.py**, **OpenWorkGraph-Agent-Node.mjs** and **OpenWorkGraph-Agent-Node.d.ts** as standalone release assets. diff --git a/README.md b/README.md index 6c5325dd..9cdb2446 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,13 @@ context:read transfers:read ``` -For larger deployments the Gateway can sit behind customer-controlled OAuth/OIDC/SSO infrastructure. Native enterprise identity provisioning is layered separately from the core data plane. +Administrators use named accounts at `https:///admin`: password plus authenticator app, or company sign-in (OpenID Connect). + +- **Roles:** owner, admin or read-only viewer. +- **Employees:** a roster, with personal invitations that tie each computer to one employee. +- **Employee view:** each employee can see what the Gateway holds about them, and every recorded read, at `/me`. + +See [docs/ORGANIZATION_ROLLOUT.md](docs/ORGANIZATION_ROLLOUT.md). --- diff --git a/VERSION b/VERSION index 5f8cbfdb..05e39cbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.95.0 +0.97.0 diff --git a/dashboard/first_value_activation.js b/dashboard/first_value_activation.js new file mode 100644 index 00000000..0fcf607e --- /dev/null +++ b/dashboard/first_value_activation.js @@ -0,0 +1,281 @@ +(() => { + 'use strict'; + + const DISMISSED_KEY = 'owg_first_value_dismissed_v1'; + const VIEWED_KEY = 'owg_first_value_reconstruction_viewed_at'; + const POLL_MS = 15000; + const AGENT_POLL_MS = 30000; + let latest = null; + let busy = false; + let agentCache = {executions:[]}; + let agentCacheAt = 0; + + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const n = value => Number.isFinite(Number(value)) ? Number(value) : 0; + const safeArray = value => Array.isArray(value) ? value : []; + + function dismissed(){ + try{return localStorage.getItem(DISMISSED_KEY)==='1';}catch(_){return false;} + } + function markDismissed(){ + try{localStorage.setItem(DISMISSED_KEY,'1');}catch(_){} + document.querySelector('#firstValueCard')?.remove(); + } + function milestone(name){ + try{const key=`owg_first_value_${name}_at`;if(!localStorage.getItem(key))localStorage.setItem(key,new Date().toISOString());}catch(_){} + } + function markViewed(){ + try{if(!localStorage.getItem(VIEWED_KEY))localStorage.setItem(VIEWED_KEY,new Date().toISOString());}catch(_){} + } + + async function getJson(url){ + await window.__owgAuthReady; + const response = await fetch(url,{cache:'no-store'}); + if(!response.ok)throw new Error(`GET ${url} failed`); + return response.json(); + } + + function surfaceOf(item){ + const host=String(item?.hostname||'').trim().toLowerCase(); + if(host){ + const parts=host.replace(/^www\./,'').split('.'); + if(parts.length>1)return parts.slice(0,-1).join('.'); + return host; + } + return String(item?.app||'Unknown surface').trim()||'Unknown surface'; + } + + function actionOf(item){ + const label=String(item?.label||'').trim(); + const action=String(item?.action||'').trim().replace(/[_-]+/g,' '); + if(label&&label.toLowerCase()!==surfaceOf(item).toLowerCase())return label.slice(0,120); + return action ? action.charAt(0).toUpperCase()+action.slice(1) : 'Observed activity'; + } + + function recentWindow(summary){ + const rows=safeArray(summary?.recent_evidence).slice().reverse(); + const cutoff=Date.now()-(15*60*1000); + const filtered=rows.filter(row=>{ + const t=Date.parse(String(row?.observed_at||'')); + return !Number.isFinite(t)||t>=cutoff; + }); + return filtered.length?filtered:rows.slice(-30); + } + + function collapsedEvidence(summary){ + const rows=recentWindow(summary); + const output=[]; + for(const row of rows){ + const surface=surfaceOf(row),action=actionOf(row),observed_at=row?.observed_at||''; + const prior=output[output.length-1]; + if(prior&&prior.kind==='human'&&prior.surface===surface&&prior.action===action){ + prior.count+=1;prior.observed_at=observed_at||prior.observed_at;continue; + } + output.push({kind:'human',surface,action,observed_at,count:1,source:String(row?.source||'')}); + } + return output; + } + + function transitionFallback(summary){ + return safeArray(summary?.transitions).slice(0,8).map(row=>({ + kind:'human', + surface:`${String(row?.from||'Work surface')} → ${String(row?.to||'Work surface')}`, + action:`Observed transition${n(row?.count)>1?` · ${n(row?.count)} times`:''}`, + observed_at:'',count:1,source:'summary-transition' + })); + } + + function agentItems(agentPayload){ + const result=[]; + for(const run of safeArray(agentPayload?.executions)){ + const agent=run?.agent||{}; + const name=agent.framework||agent.provider||agent.name||'Agent'; + const operations=run?.operation_counts||{}; + const tools=n(operations.tool_call); + const models=n(operations.model_call); + const approvals=n(run?.approval_request_count); + const failures=n(run?.failure_count)+n(operations.error); + const status=String(run?.outcome_status||'observed'); + const details=[]; + if(models)details.push(`${models} model ${models===1?'call':'calls'}`); + if(tools)details.push(`${tools} tool ${tools===1?'call':'calls'}`); + if(approvals)details.push(`${approvals} approval ${approvals===1?'request':'requests'}`); + if(failures)details.push(`${failures} observed ${failures===1?'failure':'failures'}`); + details.push(status); + result.push({ + kind:'agent',surface:String(name).slice(0,80),action:details.join(' · '), + observed_at:run?.ended_at||run?.started_at||'',count:1, + observation_level:String(run?.observation_level||agent?.observation_level||'partial observation') + }); + } + return result; + } + + function reconstruction(summary,agentPayload){ + let human=collapsedEvidence(summary); + if(!human.length)human=transitionFallback(summary); + const combined=[...human,...agentItems(agentPayload)]; + combined.sort((a,b)=>{ + const ta=Date.parse(String(a.observed_at||'')),tb=Date.parse(String(b.observed_at||'')); + if(!Number.isFinite(ta)&&!Number.isFinite(tb))return 0; + if(!Number.isFinite(ta))return -1;if(!Number.isFinite(tb))return 1;return ta-tb; + }); + const compact=[]; + for(const item of combined){ + const prior=compact[compact.length-1]; + if(prior&&item.kind==='human'&&prior.kind==='human'&&prior.surface===item.surface){ + if(prior.action!==item.action)prior.action=`${prior.action} → ${item.action}`.slice(0,180); + prior.count+=item.count||1;prior.observed_at=item.observed_at||prior.observed_at;continue; + } + compact.push({...item}); + } + return compact.slice(-12); + } + + function stateFrom(summary,agentPayload){ + const evidence=recentWindow(summary),recentSurfaces=new Set(evidence.map(surfaceOf).filter(Boolean)); + const summarySurfaceCount=safeArray(summary?.surfaces).length; + let recentTransitions=0,last=''; + for(const row of evidence){const s=surfaceOf(row);if(last&&s&&s!==last)recentTransitions+=1;if(s)last=s;} + const summarizedTransitions=safeArray(summary?.transitions).length; + const surfaceCount=Math.max(recentSurfaces.size,summarySurfaceCount); + const transitions=Math.max(recentTransitions,summarizedTransitions); + const totalEvents=Math.max(evidence.length,n(summary?.events)); + const runs=safeArray(agentPayload?.executions); + const agentEvents=runs.reduce((total,run)=>total+n(run?.event_count_total),0); + const ready=(totalEvents>=8&&surfaceCount>=2)||(transitions>=2&&totalEvents>=5)||agentEvents>=3; + const apps=safeArray(summary?.apps).map(x=>String(x?.app||'').toLowerCase()); + const browserHeavy=apps.some(x=>/(chrome|edge|safari|firefox|arc|brave)/.test(x)); + const browserConnected=Boolean(summary?.browser_sensor?.status==='connected'||summary?.browser_sensor?.connected||summary?.browser_sensor?.paired||summary?.browser_sensor?.active); + const shallowAgent=runs.some(run=>['os_observed','outcome_only'].includes(String(run?.observation_level||run?.agent?.observation_level||''))); + return {evidence_count:totalEvents,surface_count:surfaceCount,transitions,agent_runs:runs.length,agent_events:agentEvents,ready,browserHeavy,browserConnected,shallowAgent}; + } + + function aiEnabled(payload){return Boolean(payload?.enabled??payload?.ai_access_enabled??payload?.access?.enabled);} + + function ensureStyle(){ + if(document.querySelector('#first-value-style'))return; + const style=document.createElement('style');style.id='first-value-style'; + style.textContent=` + .first-value-card{border:2px solid #285e42;background:linear-gradient(135deg,#ffffff,#f5faf6)} + .first-value-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.first-value-head button{min-height:32px;padding:4px 8px} + .first-value-progress{display:flex;gap:8px;flex-wrap:wrap;margin:13px 0}.first-value-progress span{border:1px solid #d8e5dc;background:#fff;border-radius:999px;padding:6px 9px;font-size:12px} + .first-value-ready{padding:10px 12px;border-radius:11px;background:#edf7f0;color:#285e42;font-weight:750;margin:10px 0} + .first-value-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.first-value-actions button{min-height:38px} + .first-value-trace{margin:10px 0 0;padding:0;list-style:none}.first-value-trace li{display:grid;grid-template-columns:18px minmax(0,1fr);gap:8px;padding:8px 0;border-bottom:1px solid #eceee8}.first-value-trace li:last-child{border-bottom:0}.first-value-node{width:9px;height:9px;border-radius:50%;background:#285e42;margin-top:5px}.first-value-node.agent{background:#3159a5}.first-value-trace strong{font-size:13px}.first-value-trace .muted{margin-top:2px} + `; + document.head.appendChild(style); + } + + function ensureCard(){ + if(dismissed())return null; + const overview=document.querySelector('#panel-overview');if(!overview)return null; + let card=document.querySelector('#firstValueCard'); + if(card)return card; + card=document.createElement('div');card.id='firstValueCard';card.className='card first-value-card'; + card.innerHTML=`
FIRST VALUE

See what OpenWorkGraph understands

Keep working normally. OpenWorkGraph will use the evidence already being captured on this computer to show you a factual reconstruction.
Waiting for enough observed activity to form a useful reconstruction.
`; + const onboarding=document.querySelector('#historyOnboarding'); + if(onboarding&&onboarding.parentNode===overview)onboarding.insertAdjacentElement('afterend',card);else overview.insertBefore(card,overview.firstChild); + card.querySelector('#firstValueDismiss').onclick=markDismissed; + return card; + } + + function hideStaticTimelinePlaceholder(){ + const overview=document.querySelector('#panel-overview');if(!overview)return; + for(const card of overview.querySelectorAll('.card')){ + const text=card.textContent||''; + if(text.includes('Timeline lanes will activate in the stacked capture/timeline PR. Existing evidence collection is unchanged.'))card.style.display='none'; + } + } + + function hideEmptyPlaceholders(summary){ + hideStaticTimelinePlaceholder(); + const patternList=document.querySelector('#patternList'); + if(patternList){ + const card=patternList.closest('.card'); + if(card)card.style.display=n(summary?.repeated_task_pattern_count)>0?'':'none'; + } + } + + function render(state,summary,agentPayload,aiPayload){ + hideEmptyPlaceholders(summary); + if(state.evidence_count>0)milestone('first_event'); + if(state.surface_count>1)milestone('first_cross_surface'); + if(state.agent_runs>0)milestone('first_agent_run'); + if(state.ready)milestone('ready'); + const card=ensureCard();if(!card)return; + card.querySelector('#firstValueProgress').innerHTML=`${state.evidence_count} observed events${state.surface_count} work ${state.surface_count===1?'surface':'surfaces'}${state.transitions} observed ${state.transitions===1?'transition':'transitions'}${state.agent_runs} agent ${state.agent_runs===1?'run':'runs'}`; + const status=card.querySelector('#firstValueStatus'),actions=card.querySelector('#firstValueActions'); + if(state.ready){ + status.className='first-value-ready';status.textContent='OpenWorkGraph has enough observed activity to show a reconstruction. No AI interpretation is required for this view.'; + actions.innerHTML=''; + card.querySelector('#firstValueSee').onclick=()=>openReconstruction(summary,agentPayload,aiPayload,state); + }else{ + status.className='note'; + status.textContent=state.evidence_count===0?'No activity from this run has reached the local evidence store yet. Keep working normally.':'Evidence is arriving. A few more actions or a cross-tool transition will make the reconstruction more useful.'; + actions.innerHTML=''; + card.querySelector('#firstValueEvidence').onclick=()=>window.activateTab?.('evidence'); + } + } + + function traceHtml(items){ + if(!items.length)return '
There is not enough bounded recent evidence to render a trace yet.
'; + return `
    ${items.map(item=>`
  1. ${esc(item.surface)}
    ${esc(item.action)}${item.count>1?` · ${item.count} observations`:''}${item.kind==='agent'?` · ${esc(item.observation_level)}`:''}
  2. `).join('')}
`; + } + + function openReconstruction(summary,agentPayload,aiPayload,state){ + markViewed(); + const items=reconstruction(summary,agentPayload),enabled=aiEnabled(aiPayload); + const prompt='Using OpenWorkGraph, reconstruct what I was doing during the last 10 minutes. Distinguish observed facts from inference.'; + let next=''; + if(enabled){ + next=`

Let your AI inspect the same work

AI access is currently on. Ask it to use OpenWorkGraph rather than relying on chat context alone.
${esc(prompt)}
`; + }else{ + next='

Let your AI understand this too

AI access is still off. Connecting an AI is optional and does not change capture or retention.
'; + } + if(state.browserHeavy&&!state.browserConnected)next+='
Browser context can be richer. Your work includes a browser, but no active browser sensor was detected. The browser sensor is optional.
'; + if(state.shallowAgent)next+='
Agent internals are only partially observed. You can add native/OTel telemetry for deeper structural traces without capturing prompts or responses.
'; + const body=`
Observed evidence only. This reconstruction is assembled from the current session's existing privacy-hardened evidence. It does not infer intent or read hidden reasoning.
${traceHtml(items)}${next}`; + if(typeof window.openModal==='function')window.openModal('Your last few minutes','Observed reconstruction',body);else alert(items.map(x=>`${x.surface}: ${x.action}`).join('\n')); + setTimeout(()=>{ + const copy=document.querySelector('#firstValueCopyPrompt');if(copy)copy.onclick=async()=>{try{await navigator.clipboard.writeText(prompt);copy.textContent='Copied';}catch(_){window.prompt('Copy this:',prompt);}}; + const connect=document.querySelector('#firstValueConnectAI');if(connect)connect.onclick=()=>{window.closeModal?.();window.activateTab?.('connect');}; + const browser=document.querySelector('#firstValueBrowser');if(browser)browser.onclick=()=>{window.closeModal?.();window.activateTab?.('organization');}; + const agent=document.querySelector('#firstValueAgent');if(agent)agent.onclick=()=>{window.closeModal?.();window.activateTab?.('connect');setTimeout(()=>document.querySelector('#agent-observation-setup')?.scrollIntoView({behavior:'smooth',block:'start'}),0);}; + },0); + } + + function shouldPoll(force){ + if(dismissed())return false; + if(force)return true; + const overview=document.querySelector('#tab-overview'); + return !document.hidden&&(!overview||overview.getAttribute('aria-selected')==='true'); + } + + async function refresh(force=false){ + if(busy||!shouldPoll(force))return;busy=true; + try{ + const now=Date.now(); + const agentPromise=(now-agentCacheAt>=AGENT_POLL_MS) + ? getJson('/v1/agent-execution-traces?limit=10&evidence_limit=3000&max_events_per_execution=20') + : Promise.resolve(agentCache); + const [summaryResult,agentResult,aiResult]=await Promise.allSettled([ + getJson('/v1/summary?scope=current&limit=500'),agentPromise,getJson('/v1/ai-access') + ]); + if(summaryResult.status!=='fulfilled')return; + if(agentResult.status==='fulfilled'){agentCache=agentResult.value;agentCacheAt=now;} + const summary=summaryResult.value,agentPayload=agentCache,aiPayload=aiResult.status==='fulfilled'?aiResult.value:{}; + const state=stateFrom(summary,agentPayload);latest={summary,agentPayload,aiPayload,state};render(state,summary,agentPayload,aiPayload); + }finally{busy=false;} + } + + function install(){ + ensureStyle();hideStaticTimelinePlaceholder();refresh(true); + document.querySelector('#tab-overview')?.addEventListener('click',()=>setTimeout(()=>refresh(true),0)); + setInterval(()=>refresh(false),POLL_MS); + } + + window.refreshFirstValue=()=>refresh(true); + window.firstValueReconstruction=()=>latest?openReconstruction(latest.summary,latest.agentPayload,latest.aiPayload,latest.state):refresh(true); + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',install);else install(); +})(); diff --git a/dashboard/org_join.js b/dashboard/org_join.js index fea354f7..d0f26026 100644 --- a/dashboard/org_join.js +++ b/dashboard/org_join.js @@ -42,13 +42,50 @@ '' + '
' + '' + '
'; const gatewayPanel = $('gatewayPanel'); panel.insertBefore(card, gatewayPanel || managedNote.nextSibling); + + const meCard = document.createElement('div'); + meCard.id = 'orgMeCard'; + meCard.className = 'card'; + meCard.hidden = true; + meCard.innerHTML = '

What your organization holds about you

' + + '
Open a page on your organization\'s Gateway that shows exactly what it received from your computers, who can read it, and every recorded read. The link is personal, works once and expires in two minutes.
' + + '
' + + '
'; + panel.insertBefore(meCard, card); + } + } + + async function refreshMe() { + try { + const st = await call('/v1/gateway-status'); + const card = $('orgMeCard'); + if (card) card.hidden = !st.enrolled; + } catch (_) { /* local-only */ } + } + + async function openMe() { + $('orgMeResult').textContent = ''; + // Open the tab during the click; browsers block popups opened after an await. + const tab = window.open('', '_blank'); + if (tab) tab.opener = null; + try { + const r = await call('/v1/org-me-link', {}); + if (tab) { tab.location.replace(r.url); return; } + const a = document.createElement('a'); + a.href = r.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; + a.textContent = 'Open your page (the link works once, for two minutes)'; + $('orgMeResult').replaceChildren(a); + } catch (e) { + if (tab) tab.close(); + $('orgMeResult').textContent = e.message; } } @@ -91,6 +128,22 @@ previewed = await call('/v1/org-join/preview', {join_code: code}); $('orgJoinPreview').hidden = false; $('orgJoinOrg').textContent = previewed.organization_name; + const id = previewed.identity || {}; + const needsConfirm = !!(id.locked && id.require_sso && !id.sso_verified); + $('orgJoinActorBox').hidden = !!id.locked; + $('orgJoinIdentity').hidden = !id.locked; + if (id.locked) { + $('orgJoinIdentity').innerHTML = `
You will join as: ${esc(id.display_name || id.email)} (${esc(id.email)})
` + + '
This invitation is personal: it can only connect computers as this person.
' + + (needsConfirm + ? '
First confirm it\'s you with your company account.
' + : (id.require_sso ? '
✓ Confirmed with your company account.
' : '')); + const c = $('orgJoinConfirm'); + if (c) c.addEventListener('click', () => window.open(id.verify_url, '_blank', 'noopener')); + const r = $('orgJoinRecheck'); + if (r) r.addEventListener('click', preview); + } + $('orgJoinButton').disabled = needsConfirm; $('orgJoinShares').innerHTML = sharingList(previewed.sharing) + '

Never shared: ' + esc((previewed.never_shared || []).join(', ')) + '.

' + '

You can: ' + esc((previewed.you_can || []).join(', ')) + '.

'; @@ -103,8 +156,9 @@ async function join() { if (!previewed) return; - const actor = $('orgJoinActor').value.trim(); - if (!actor) { + const locked = !!(previewed.identity && previewed.identity.locked); + const actor = locked ? '' : $('orgJoinActor').value.trim(); + if (!locked && !actor) { $('orgJoinResult').textContent = 'Enter your work email or username.'; return; } @@ -121,6 +175,7 @@ }); previewed = null; $('orgJoinCard').innerHTML = `

Joined ${esc(r.organization_name)}

Sharing starts from enrollment forward. Anything recorded before joining stays on this computer. You can pause or disconnect in the Organization section.
`; + refreshMe(); if (typeof window.refreshGatewayPanel === 'function') window.refreshGatewayPanel(); } catch (e) { $('orgJoinResult').textContent = e.message; @@ -163,6 +218,9 @@ if (p) p.addEventListener('click', preview); const j = $('orgJoinButton'); if (j) j.addEventListener('click', join); + const m = $('orgMeButton'); + if (m) m.addEventListener('click', openMe); setTimeout(refreshManaged, 300); + setTimeout(refreshMe, 300); }); })(); diff --git a/deploy/.env.example b/deploy/.env.example index 403c21ac..679a3204 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -46,3 +46,22 @@ OWG_GATEWAY_AGGREGATE_MIN_ACTORS=5 # Use a stable random secret >=32 characters; rotation intentionally changes all # pseudonyms. Pseudonymization is NOT anonymization. OWG_GATEWAY_PSEUDONYM_KEY= + +# The Gateway's public HTTPS address, e.g. https://owg.example.com. Used for +# company sign-in redirects and for invitation links. Required for SSO. +OWG_GATEWAY_PUBLIC_URL= + +# Optional company sign-in (OpenID Connect) for administrators (/admin), +# employees (/me) and invitation confirmation (/join/verify). Register the +# redirect URI /sso/callback with your identity +# provider. The issuer defaults to OWG_GATEWAY_OIDC_ISSUER when left blank. +OWG_GATEWAY_SSO_ISSUER= +OWG_GATEWAY_SSO_CLIENT_ID= +OWG_GATEWAY_SSO_CLIENT_SECRET= +# Optional comma-separated list, e.g. acme.se,acme.com +OWG_GATEWAY_SSO_ALLOWED_DOMAINS= + +# OWG_GATEWAY_ADMIN_TOKEN creates the first administrator at /admin. Once your +# named administrators are set up, set this to "disabled" so the shared token +# can no longer call admin APIs (it can still create the first owner if none exists). +OWG_GATEWAY_ADMIN_TOKEN_API=enabled diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 8f7ce556..aba0f2c0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -42,6 +42,12 @@ services: OWG_GATEWAY_OIDC_SELF_READ: ${OWG_GATEWAY_OIDC_SELF_READ:-true} OWG_GATEWAY_AGGREGATE_MIN_ACTORS: ${OWG_GATEWAY_AGGREGATE_MIN_ACTORS:-5} OWG_GATEWAY_PSEUDONYM_KEY: ${OWG_GATEWAY_PSEUDONYM_KEY:-} + OWG_GATEWAY_ADMIN_TOKEN_API: ${OWG_GATEWAY_ADMIN_TOKEN_API:-enabled} + OWG_GATEWAY_PUBLIC_URL: ${OWG_GATEWAY_PUBLIC_URL:-} + OWG_GATEWAY_SSO_ISSUER: ${OWG_GATEWAY_SSO_ISSUER:-} + OWG_GATEWAY_SSO_CLIENT_ID: ${OWG_GATEWAY_SSO_CLIENT_ID:-} + OWG_GATEWAY_SSO_CLIENT_SECRET: ${OWG_GATEWAY_SSO_CLIENT_SECRET:-} + OWG_GATEWAY_SSO_ALLOWED_DOMAINS: ${OWG_GATEWAY_SSO_ALLOWED_DOMAINS:-} ports: - "${OWG_GATEWAY_BIND_ADDRESS:-127.0.0.1}:${OWG_GATEWAY_PORT:-8790}:8790" diff --git a/docs/CHANGELOG_V096.md b/docs/CHANGELOG_V096.md new file mode 100644 index 00000000..d6a7cf91 --- /dev/null +++ b/docs/CHANGELOG_V096.md @@ -0,0 +1,22 @@ +# OpenWorkGraph v0.96 — first useful reconstruction + +v0.96 adds a thin first-run activation layer over the existing local evidence stack. It is intended to make OpenWorkGraph understandable during the first real work session without changing what the product captures or what an AI may access. + +## What changes + +- Overview shows a dismissible **See what OpenWorkGraph understands** card. +- Progress is evidence-driven rather than a countdown: observed events, work surfaces, transitions and agent runs. +- Once enough current-session evidence exists, **Your last few minutes** presents a deterministic reconstruction from existing privacy-hardened dashboard evidence and structural agent-run reports. +- If interaction-level evidence is sparse, already-derived observed surface transitions provide a factual fallback rather than inventing task intent. +- The next action is contextual: Connect AI only when the user chooses, optional browser-sensor setup when browser context is shallow, and optional native/OTel agent telemetry when an agent is only surface-observed. +- The old unfinished timeline placeholder and an empty repeated-workflows card are suppressed on first run until they have useful content. Their underlying DOM/data paths remain intact. + +## What does not change + +v0.96 adds no capture sensor, screenshot capture, filesystem watcher, prompt/response capture, clipboard-content capture, browser/OS permission, database schema, retention rule, AI permission, saved-history lease, Gateway behavior, agent-ingest permission, export format or MCP tool. + +The first-value layer performs GET requests only against existing authenticated local endpoints. It cannot enable AI access, save history, synchronize evidence or mutate canonical evidence. + +## Compatibility goal + +Evidence, History, Agents, Connect, Organization and Export remain the established advanced surfaces. Dismissing the first-value card leaves the ordinary dashboard behavior intact. Existing installations and existing MCP configurations keep their prior semantics. diff --git a/docs/CHANGELOG_V097.md b/docs/CHANGELOG_V097.md new file mode 100644 index 00000000..4e6c2664 --- /dev/null +++ b/docs/CHANGELOG_V097.md @@ -0,0 +1,44 @@ +# OpenWorkGraph v0.97 — Gateway identity and employee transparency + +v0.97 adds an enterprise identity layer to the optional customer-controlled Gateway while preserving the local-first evidence, v0.96 first-value activation, retention, MCP, agent-ingest and export behavior. + +## Named Gateway administrators + +- `/admin` supports named **owner**, **admin** and **viewer** accounts instead of requiring every administrator to act anonymously through one shared token. +- Administrators can sign in with password + authenticator code (TOTP), or with company OpenID Connect when configured. +- The first owner is bootstrapped with `OWG_GATEWAY_ADMIN_TOKEN`; subsequent administrators get single-use setup links. +- Sign-in sessions have idle and absolute expiry, repeated failures are throttled/locked, and TOTP time steps cannot be reused. +- The bootstrap token can be disabled for normal admin API use after named accounts exist. If an installation loses its only owner's credentials, deliberately re-enabling/presenting the bootstrap token can reset that owner without weakening the normal last-owner protection. +- Audit rows attribute actions to the named administrator when one is signed in. + +## Employee roster and identity-bound enrollment + +- Administrators can maintain an employee roster manually or by CSV, including team membership. +- Personal invitations lock enrollment to one roster employee; the computer cannot substitute another actor identity. +- A computer records how its identity was established: SSO-confirmed, personal invitation, linked by an administrator, or self-reported legacy/group enrollment. +- Organizations can require identity-bound personal invitations for all new computers. +- Offboarding revokes the employee's active device credentials and open invitations without silently deleting retained evidence. +- When SSO is required, one successful company-account confirmation authorizes **one** computer enrollment. A multi-device invitation requires a fresh confirmation for each additional computer, so a copied invite cannot reuse an earlier SSO proof. + +## Employee `/me` view + +- An enrolled employee can open `/me` from their own local OpenWorkGraph using a single-use, two-minute login code minted with the device credential. +- When company sign-in is enabled, `/me` may also use the roster-matching company account. +- The page is scoped to that employee and shows their connected computers, evidence held by the Gateway, organization sharing ceiling, readers with explicit access and recorded reads/changes involving them. +- Viewing evidence through `/me` is itself audited. +- If the same email belongs to more than one organization on a multi-tenant Gateway, generic SSO sign-in refuses to guess; the employee must open `/me` from an enrolled computer (or otherwise provide an organization-specific context). + +## Company sign-in + +- OpenID Connect authorization-code flow with PKCE (S256), nonce, single-use state, issuer/audience/expiry validation and provider-published signing keys. +- If the provider explicitly sends `email_verified: false`, sign-in is refused. Some enterprise providers omit that optional claim; in that case OWG still requires a cryptographically verified ID token, a usable email/UPN claim, optional allowed-domain match, and a matching known administrator/roster employee for the requested action. +- Optional `OWG_GATEWAY_SSO_ALLOWED_DOMAINS` can restrict accepted company-account domains. +- `OWG_GATEWAY_PUBLIC_URL` supplies the HTTPS redirect origin; Docker/self-host configuration passes the identity settings explicitly. + +## Security and compatibility + +- Setup/session/invitation secrets used by browser pages travel in URL fragments and are removed from the address bar on arrival; Gateway pages use no external scripts and use CSP/nonces, `no-store` and frame denial. +- Existing admin API endpoints remain available to the bootstrap token until the operator disables that path. +- Existing group invitations, managed enrollment, legacy enrollment and already-enrolled devices continue to work. Their identity is shown as self-reported until linked where appropriate. +- Existing v0.96 first-value activation, History/Retention, MCP contracts, agent telemetry, organization sharing ceilings and canonical evidence formats are preserved. +- No SCIM provisioning is included in this release. diff --git a/docs/ORGANIZATION_ROLLOUT.md b/docs/ORGANIZATION_ROLLOUT.md index dd6d9c31..4741b01b 100644 --- a/docs/ORGANIZATION_ROLLOUT.md +++ b/docs/ORGANIZATION_ROLLOUT.md @@ -2,25 +2,51 @@ OpenWorkGraph stays local-first. An organization can optionally run its own Gateway and let enrolled employee computers synchronize only the evidence allowed by both the employee endpoint and the organization policy. -## Two deliberately different screens +## Where everything is -| Screen | Who | Where | Purpose | +| Page | Who | Address | Purpose | |---|---|---|---| -| **Organization Admin Console** | IT / Gateway administrator | `https:///admin` | Enrollment, fleet status, sharing ceilings, retention, service tokens and audit | -| **Personal dashboard · this computer** | Employee | local OpenWorkGraph dashboard | This computer's local evidence, AI connections, capture and organization-sharing controls | +| **Gateway start page** | anyone | `https:///` | Links to the two pages below; shows nothing else | +| **Organization Admin Console** | IT / Gateway administrators | `https:///admin` | Employees, invitations, computers, sharing ceilings, retention, service tokens, administrators, identity settings and audit | +| **What your organization holds about you** | each employee | `https:///me` | The employee's own computers, the evidence the Gateway holds from them, who can read it, and every recorded read | +| **Invitation confirmation** | an invited employee | `https:///join/verify` | Confirms a personal invitation with company sign-in, when the invitation requires it | +| **Personal dashboard · this computer** | each employee | local OpenWorkGraph dashboard | This computer's local evidence, AI connections, capture and organization-sharing controls | -The admin console has a distinct indigo **ADMIN** header and an explicit warning that it is not the employee dashboard. A managed employee installation visibly shows **Managed by ** and what the organization is permitted to receive. +The admin console has a distinct indigo **ADMIN** header and an explicit note that it is not the employee dashboard. The employee page has a green **YOU** header. A managed employee installation visibly shows **Managed by ** and what the organization is permitted to receive. + +Employees normally reach `/me` from their own OpenWorkGraph: **Organization → What your organization holds about you → See what your organization holds about you**. That button mints a personal link that works once, for two minutes, and signs the employee in on the Gateway as the person their computer is enrolled as. With company sign-in configured, employees can also open `/me` directly and sign in. ## 1. Prepare the Gateway -1. Deploy the Gateway as described in [SELF_HOSTING.md](SELF_HOSTING.md), preferably behind HTTPS with PostgreSQL for a real pilot. -2. Open `https:///admin`. -3. Sign in with the configured `OWG_GATEWAY_ADMIN_TOKEN`, then enter the organization ID and display name. -4. Open **Sharing policy** and set the organization's sharing ceiling. +1. Deploy the Gateway as described in [SELF_HOSTING.md](SELF_HOSTING.md), behind HTTPS and with PostgreSQL for a real pilot. Set `OWG_GATEWAY_PUBLIC_URL` to its public address. +2. Open `https:///admin`. With no administrator yet, the console asks for the bootstrap token (`OWG_GATEWAY_ADMIN_TOKEN`) and your work email, and creates you as the first **owner**. +3. Set up your account on the next screen: add the Gateway to an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password or similar), choose a password of at least 12 characters, and confirm with a code. You are signed in. +4. Add other administrators under **Administrators** (next section). +5. Open **Sharing policy** and set the organization's sharing ceiling. +6. Once your administrators are set up, set `OWG_GATEWAY_ADMIN_TOKEN_API=disabled` and restart, so the shared token can no longer administer the Gateway. + +## 2. Administrators + +Every administrator has a named account. Admin actions in the audit log show who did them (`admin:it@acme.se`), not a shared token. + +| Role | Can | +|---|---| +| **Owner** | everything, including managing administrators | +| **Admin** | everything except managing administrators | +| **Viewer** | read-only: the console hides write controls and the Gateway refuses writes | -The admin bootstrap token is currently a shared administrator credential. For a larger rollout, put the Gateway behind the organization's normal access controls and plan named administrator SSO rather than distributing this token broadly. +- **Adding one:** an owner adds the administrator's work email and role, and sends them the one-time setup link. The link works once and expires after 72 hours; the new administrator chooses their own password and authenticator. +- **Sign-in:** email, password and a code from the authenticator app. With company sign-in configured, administrators can use **Sign in with company account** instead; the email from the identity provider must match their administrator account. +- **Sessions:** kept only in that browser tab. They end after 60 minutes idle, after 12 hours in total, and when the administrator signs out. +- **Protection:** five failed sign-ins lock the account for 15 minutes; repeated failures from one address are throttled. Each authenticator code works only once. +- **Reset sign-in:** gives the administrator a new setup link and ends their sessions. +- **Disable:** ends their sessions immediately. The last active owner cannot be demoted or disabled. -## 2. Decide what the organization may receive +**Recovery:** +- **Another owner is available:** they use **Reset sign-in** on the owner who lost their authenticator. +- **No owner is available:** set `OWG_GATEWAY_ADMIN_TOKEN_API=enabled`, restart, and open `/admin`. Choose **Use the bootstrap admin token instead**, reset the owner's sign-in, then disable the token again. Actions taken with the token are audited as `bootstrap-token`. + +## 3. Decide what the organization may receive Organization policy can restrict an endpoint; it cannot force the endpoint to share more than its own local policy permits. @@ -37,27 +63,49 @@ Permitting agent activity does not enable it for an employee. Agent evidence is Typed text, clipboard contents, password values and screenshot bytes are outside the Gateway sharing contract. -## 3. Invite employees +## 4. Add employees + +Under **Employees**, add people one at a time (work email, name, teams) or paste a CSV: + +```text +email,name,teams +anna.svensson@acme.se,Anna Svensson,sales;nordics +erik.lindqvist@acme.se,Erik Lindqvist,support +``` + +The header row is optional. Rows with errors are reported by line number and the rest are imported. Importing an existing email updates the name and teams. -### Option A — reusable join code +An employee's identity on the Gateway is their normalized work email. Teams decide which team leads can read their evidence through the human access API (see [HUMAN_ACCESS.md](HUMAN_ACCESS.md)). -In **Invite employees**, choose a label, maximum number of computers and expiry. One invitation can enroll multiple computers, while each successful enrollment receives its own device credential. +## 5. Invite employees' computers -Send the generated `owgjoin1.…` code to the intended employees. Treat it like an enrollment secret: it cannot read organization evidence, but until it expires, is exhausted or is revoked, it can enroll another computer. +### Recommended — personal invitations -The employee opens OpenWorkGraph → **Organization** → **Join your organization** and: +Click **Personal invite** next to an employee. The console shows an `owgjoin1.…` code and a ready-to-send message. -1. pastes the code; -2. reviews the organization name and exactly what the Gateway policy permits; -3. enters their work identity; -4. confirms that they reviewed the sharing preview; -5. joins. +- **Locked identity:** the invitation can only connect computers as that employee. OpenWorkGraph shows "You will join as Anna Svensson (anna.svensson@acme.se)"; there is no identity field to type into, and the Gateway ignores any identity the computer sends. +- **Limits:** valid for 7 days and up to 2 computers. Through the admin API (`expires_days`, `max_devices`), up to 30 days and 5 computers. +- **Consuming a seat:** previewing never consumes one, and neither does a rejected enrollment. A seat is used in the same database transaction that creates the computer's credential. +- **Company sign-in:** when SSO is configured, a personal invitation requires the employee to confirm with their company account first. OpenWorkGraph shows **Confirm with company account**, which opens `/join/verify` on the Gateway. The **Join** button stays disabled until the sign-in matches the invited email. Signing in as someone else is refused. -Previewing never consumes a seat. A rejected enrollment does not consume a seat either. A seat is committed in the same database transaction that creates the device credential. +Employees join from OpenWorkGraph → **Organization** → **Join your organization**: -### Option B — managed devices / MDM +1. Paste the code. +2. Review the organization name and exactly what the Gateway policy permits. +3. Confirm with the company account, if asked. +4. Confirm that you reviewed the sharing preview. +5. Join. -The admin console can download a small `managed.json` containing the join code. Deploy it with OpenWorkGraph through Jamf, Intune or another device-management system to: +### Group invitations and managed devices + +**Group invitations** (one reusable code for many computers) and the managed `managed.json` file still work. With these, the employee types their identity, or it is derived on the computer. The Gateway cannot verify it. + +Such computers appear under **Employees → Computers not linked to an employee** as **self-reported**. For each one, either: + +- **link it** to the right employee, optionally re-attributing the evidence it already sent; or +- **revoke** it. + +The managed file is deployed with OpenWorkGraph through Jamf, Intune or another device-management system to: - macOS: `/Library/Application Support/OpenWorkGraph/managed.json` - Windows: `C:\ProgramData\OpenWorkGraph\managed.json` @@ -69,11 +117,71 @@ On first launch, OpenWorkGraph previews the Gateway policy and joins automatical Identity defaults to the OS username, optionally combined with `email_domain`. For environments where OS usernames do not map cleanly to work identities, deploy an explicit `actor_id` per computer instead. -Use short invitation expiry periods and an appropriate seat limit for managed deployment, protect the managed file with normal device-management permissions, and revoke the invitation after the intended rollout is complete. +Use short invitation expiry periods and an appropriate seat limit for managed deployment, and protect the managed file with normal device-management permissions. Revoke the invitation after the intended rollout is complete. If a computer is already enrolled with a different Gateway or organization, managed setup fails visibly instead of silently replacing the existing enrollment. + +### Requiring verified identities + +Under **Identity & sign-in**, **Require a personal invitation for every new computer** turns off group invitations, managed files, single-use enrollment codes and the legacy enrollment token for new computers. Computers already enrolled are not affected; link or revoke them on the Employees tab. + +### How each computer's identity was established + +| Label | Meaning | +|---|---| +| **SSO verified** | joined with a personal invitation confirmed by company sign-in | +| **personal invite** | joined with a personal invitation | +| **linked by admin** | joined another way; an administrator linked it to the employee | +| **self-reported** | joined with a group invitation, managed file, single-use enrollment code or legacy token; identity typed or derived on the computer | + +## 6. Company sign-in (SSO) + +Company sign-in is optional. Without it, administrators use a password and authenticator app, and employees reach `/me` from their own OpenWorkGraph. + +1. In your identity provider (Entra ID, Okta, Google Workspace, Keycloak or any OpenID Connect provider), register a web application: + - redirect URI: `https:///sso/callback`; + - scopes: `openid email profile`. +2. Set, then restart the Gateway: + + ```text + OWG_GATEWAY_PUBLIC_URL=https:// + OWG_GATEWAY_SSO_ISSUER=https://login.example.com/... # defaults to OWG_GATEWAY_OIDC_ISSUER + OWG_GATEWAY_SSO_CLIENT_ID=... + OWG_GATEWAY_SSO_CLIENT_SECRET=... # omit for a public client + OWG_GATEWAY_SSO_ALLOWED_DOMAINS=acme.se,acme.com # optional + ``` + +3. **Identity & sign-in** now shows the issuer and redirect URI. The sign-in screens offer **Sign in with company account**. + +The Gateway uses the authorization-code flow with PKCE, a nonce and a single-use state that expires after 10 minutes. It verifies the ID token signature against the provider's published keys, the issuer, the audience and the expiry. Sign-in is refused when: + +- the provider marks the email unverified; +- the email's domain is not in the allowed list; +- the email does not belong to an administrator (for `/admin`) or a roster employee (for `/me`). + +The first successful company sign-in links the employee's provider subject to their roster entry. Human access API tokens that identify people by an opaque `sub` then read that employee's own evidence. + +## 7. What employees can see about themselves + +`/me` shows the employee, and only the employee: + +- **Evidence:** how much the Gateway holds from them, and exactly which events. +- **Sharing:** what their computers are allowed to share. +- **Who can read it:** + - the employee; + - team leads with access to their teams; + - the number of integrations with organization-wide read access. + + Gateway administrators manage computers and policy but cannot read evidence without an explicit access scope. +- **Computers:** each one, and how it was linked to them. +- **Recorded reads and changes:** + - their own reads and team leads' reads; + - integration reads of their evidence or of everyone's evidence; + - invitations, enrollments, links, team changes and offboarding. + +Viewing evidence on `/me` is itself recorded as a read by the employee. -If a computer is already enrolled with a different Gateway or organization, managed setup fails visibly instead of silently replacing the existing enrollment. +Employee sessions end after 30 minutes idle, after 8 hours in total, or when the employee signs out. A `/me` link from OpenWorkGraph stops working if that computer is revoked. -## 4. Privacy boundary at enrollment +## 8. Privacy boundary at enrollment Enrollment starts organization sharing from the current local evidence position. Evidence recorded before the computer joined remains local by default. @@ -81,23 +189,26 @@ Pausing organization sharing also keeps evidence local for that paused interval; Local capture continues independently if the Gateway is offline or organization sharing is paused. -## 5. Operate the pilot +## 9. Operate the pilot The admin console provides: -- **Overview** — enrolled computers, recent evidence activity, silent devices, active invitations and deployment warnings; -- **Invite employees** — reusable, expiring, revocable enrollment links; -- **Employees & devices** — actor/device identity, enrollment time, last evidence time and explicit revocation; -- **Sharing policy** — the organization sharing ceiling; -- **Retention** — Gateway retention configuration; -- **Access tokens** — scoped integration credentials, shown once at creation and revocable later; -- **Audit log** — Gateway administrative and evidence-access activity. +- **Overview:** employees, enrolled computers, recent evidence activity, silent computers, unverified identities, open group invitations and deployment warnings. +- **Employees:** the roster, personal invitations, each employee's computers and how they were linked, computers not linked to an employee, and offboarding. +- **Group invitations:** reusable, expiring, revocable enrollment links and the managed file. +- **Sharing policy:** the organization sharing ceiling. +- **Retention:** Gateway retention configuration. +- **Access tokens:** scoped integration credentials, shown once at creation and revocable later. +- **Administrators** (owners only): named administrator accounts and roles. +- **Identity & sign-in:** require verified identities, company sign-in status, bootstrap token status. +- **Audit log:** administrative, enrollment and evidence-access activity, attributed to the named administrator. -Revoking an invitation prevents new enrollments but does not revoke computers that already received device credentials. Revoke a computer separately from **Employees & devices** when required. +**Offboarding** an employee revokes all their computers and open invitations at once. Revoking a group invitation prevents new enrollments but does not revoke computers that already received credentials; revoke those separately. ## Current rollout limits -- Gateway administrator sign-in still uses the bootstrap admin token; named administrator SSO is a later hardening step. +- Group invitations and managed files still rely on identity typed or derived on the computer; require personal invitations for verified identities. +- SCIM provisioning is not included; add employees by hand or CSV, or through the admin API (`POST /v1/admin/employees/{organization}` and `/import`). - The managed paths should be dogfooded on real macOS and Windows machines before a broad fleet rollout. - The browser sensor remains a separate installation/pairing surface unless IT packages/distributes the browser extension through its own browser-management policy. - Organization rollout does not enable collection of general typed text or clipboard contents. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 2a521dc4..d6e4d4e9 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -56,6 +56,18 @@ curl http://127.0.0.1:8790/health Production deployments should terminate TLS at a customer-controlled reverse proxy/load balancer and restrict network access according to the organization's security policy. Do not place an unencrypted Gateway directly on the public internet. +Then open `https:///admin` to create the first administrator with `OWG_GATEWAY_ADMIN_TOKEN`, and follow [ORGANIZATION_ROLLOUT.md](ORGANIZATION_ROLLOUT.md). Employees see what the Gateway holds about them at `https:///me`. + +Identity settings (all optional, also listed in `deploy/.env.example`): + +| Variable | Purpose | +|---|---| +| `OWG_GATEWAY_PUBLIC_URL` | The Gateway's public HTTPS address; used for company sign-in redirects and invitation links | +| `OWG_GATEWAY_SSO_ISSUER` | OpenID Connect issuer for company sign-in (defaults to `OWG_GATEWAY_OIDC_ISSUER`) | +| `OWG_GATEWAY_SSO_CLIENT_ID` / `OWG_GATEWAY_SSO_CLIENT_SECRET` | The Gateway's client registration; redirect URI `/sso/callback` | +| `OWG_GATEWAY_SSO_ALLOWED_DOMAINS` | Comma-separated email domains allowed to sign in | +| `OWG_GATEWAY_ADMIN_TOKEN_API` | `disabled` stops the bootstrap token from calling admin APIs once named administrators exist | + ## Enroll an endpoint ### Preferred: single-use organization-bound enrollment code @@ -232,4 +244,11 @@ For automated tests and local development the Gateway also supports a SQLite URL ## Enterprise identity -v0.54 uses explicit device credentials, scoped service credentials, and preferred single-use enrollment grants so the data-plane boundary is testable without requiring a vendor cloud account. In larger deployments the Gateway can be placed behind the customer's OIDC/OAuth-aware reverse proxy/identity layer. Native enterprise SSO/group provisioning can be layered on without changing the core self-hosted data plane. +The Gateway uses explicit device credentials, scoped service credentials, and preferred single-use enrollment grants, so the data-plane boundary is testable without a vendor cloud account. + +- **Administrators:** named accounts with password and authenticator app, or company sign-in (OpenID Connect). +- **Employees:** a roster; personal invitations tie each computer to one employee. +- **Employee access:** employees open `/me` from their own OpenWorkGraph, or with company sign-in. +- **Human access API:** see [HUMAN_ACCESS.md](HUMAN_ACCESS.md). + +Setup is in [ORGANIZATION_ROLLOUT.md](ORGANIZATION_ROLLOUT.md). SCIM provisioning is not included. diff --git a/gateway/admin_accounts.py b/gateway/admin_accounts.py new file mode 100644 index 00000000..71463471 --- /dev/null +++ b/gateway/admin_accounts.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +"""Named Gateway administrator accounts. + +* Each administrator has their own account: work email, display name, role. +* Roles: ``owner`` (everything, including managing administrators), ``admin`` + (everything except managing administrators) and ``viewer`` (read-only). +* Sign-in: password (scrypt) plus a TOTP authenticator code (RFC 6238), or the + organization's SSO when configured. Accounts are activated through a + single-use setup link, so passwords are never chosen or sent by someone else. +* Sessions are random bearer tokens stored only as hashes, with an idle and an + absolute lifetime. Repeated failures lock an account and throttle the source. +* The ``OWG_GATEWAY_ADMIN_TOKEN`` bootstrap token can create the first owner + only while no administrator exists. After that it can be switched off for API + use with ``OWG_GATEWAY_ADMIN_TOKEN_API=disabled``. + +All tables work on SQLite (development) and PostgreSQL. +""" + +import base64 +import contextvars +import hashlib +import hmac +import os +import re +import secrets +import struct +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import quote + +from shared.time_utils import normalize_timestamp +from .auth import token_hash +from .db import GatewayDB + +ROLES = ("owner", "admin", "viewer") +PASSWORD_MIN_LENGTH = 12 +SESSION_IDLE_MINUTES = 60 +SESSION_ABSOLUTE_HOURS = 12 +SETUP_LINK_HOURS = 72 +LOCK_AFTER_FAILURES = 5 +LOCK_MINUTES = 15 +THROTTLE_WINDOW_MINUTES = 15 +THROTTLE_MAX_FAILURES = 20 +SESSION_PREFIX = "owg_admin_session_" +SETUP_PREFIX = "owg_admin_setup_" +_EMAIL_RE = re.compile(r"^[^@\s]{1,128}@[^@\s]{1,253}\.[^@\s]{2,63}$") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS gateway_admins ( + admin_id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL, + password_hash TEXT NOT NULL DEFAULT '', + totp_secret TEXT NOT NULL DEFAULT '', + totp_pending_secret TEXT NOT NULL DEFAULT '', + totp_last_step INTEGER NOT NULL DEFAULT 0, + setup_token_hash TEXT, + setup_expires_at TEXT, + active INTEGER NOT NULL DEFAULT 0, + disabled_at TEXT, + failed_count INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + last_login_at TEXT +); +CREATE TABLE IF NOT EXISTS gateway_admin_sessions ( + session_hash TEXT PRIMARY KEY, + admin_id TEXT NOT NULL, + auth_method TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_gateway_admin_sessions_admin ON gateway_admin_sessions(admin_id); +CREATE TABLE IF NOT EXISTS gateway_auth_throttle ( + throttle_key TEXT PRIMARY KEY, + failures INTEGER NOT NULL, + window_start TEXT NOT NULL +) +""" + + +class AdminAuthError(ValueError): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class AdminIdentity: + admin_id: str + email: str + display_name: str + role: str + auth_method: str + + @property + def audit_id(self) -> str: + return "bootstrap-token" if self.admin_id == "bootstrap" else f"admin:{self.email}" + + def can_write(self) -> bool: + return self.role in {"owner", "admin"} + + def is_owner(self) -> bool: + return self.role == "owner" + + +BOOTSTRAP_IDENTITY = AdminIdentity("bootstrap", "", "Bootstrap token", "owner", "bootstrap_token") + +# Set by the admin identity middleware for the duration of one request. +CURRENT_ADMIN: contextvars.ContextVar[AdminIdentity | None] = contextvars.ContextVar("owg_current_admin", default=None) + + +def current_admin() -> AdminIdentity | None: + return CURRENT_ADMIN.get() + + +# --- helpers ---------------------------------------------------------------------------- + +def _now_dt() -> datetime: + return datetime.now(timezone.utc) + + +def _ts(value: datetime) -> str: + return normalize_timestamp(value.isoformat()) + + +def _one(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any]: + cur = db._execute(conn, sql, params) + row = cur.fetchone() + columns = [d[0] for d in cur.description] if cur.description else None + return db._row(row, columns) + + +def _all(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + cur = db._execute(conn, sql, params) + rows = cur.fetchall() + columns = [d[0] for d in cur.description] if cur.description else None + return [db._row(r, columns) for r in rows] + + +def init_admin_schema(db: GatewayDB) -> None: + with db.connect() as conn: + for statement in [x.strip() for x in SCHEMA.split(";") if x.strip()]: + conn.execute(statement) + + +def normalize_email(value: str) -> str: + email = str(value or "").strip().lower() + if not _EMAIL_RE.fullmatch(email): + raise AdminAuthError("enter a valid work email address") + return email + + +def bootstrap_token_api_enabled() -> bool: + return str(os.getenv("OWG_GATEWAY_ADMIN_TOKEN_API", "enabled")).strip().lower() not in {"disabled", "off", "0", "false"} + + +# --- passwords ----------------------------------------------------------------------------- + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32) + return "scrypt$16384$8$1$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode() + + +def verify_password(password: str, stored: str) -> bool: + try: + scheme, n, r, p, salt_b64, digest_b64 = stored.split("$") + if scheme != "scrypt": + return False + expected = base64.b64decode(digest_b64) + actual = hashlib.scrypt( + password.encode("utf-8"), salt=base64.b64decode(salt_b64), + n=int(n), r=int(r), p=int(p), dklen=len(expected), + ) + return hmac.compare_digest(actual, expected) + except Exception: + return False + + +def check_password_strength(password: str, email: str = "") -> None: + value = str(password or "") + if len(value) < PASSWORD_MIN_LENGTH: + raise AdminAuthError(f"use at least {PASSWORD_MIN_LENGTH} characters") + if len(value) > 256: + raise AdminAuthError("password is too long") + if email and email.split("@", 1)[0] and email.split("@", 1)[0].lower() in value.lower(): + raise AdminAuthError("the password must not contain your email name") + if len(set(value)) < 6: + raise AdminAuthError("the password is too repetitive") + + +# --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ------------------------------------------------- + +def new_totp_secret() -> str: + return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") + + +def _totp_at(secret: str, step: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{code:06d}" + + +def totp_now(secret: str, at: float | None = None) -> str: + return _totp_at(secret, int((at if at is not None else time.time()) // 30)) + + +def verify_totp(secret: str, code: str, *, last_step: int = 0, at: float | None = None) -> int | None: + """Return the matched time step (±1 step drift), refusing reuse of a spent step.""" + value = re.sub(r"\s+", "", str(code or "")) + if not re.fullmatch(r"\d{6}", value) or not secret: + return None + now_step = int((at if at is not None else time.time()) // 30) + for step in (now_step - 1, now_step, now_step + 1): + if step > last_step and hmac.compare_digest(_totp_at(secret, step), value): + return step + return None + + +def otpauth_uri(secret: str, email: str, issuer: str = "OpenWorkGraph Gateway") -> str: + return ( + f"otpauth://totp/{quote(issuer)}:{quote(email)}?secret={secret}" + f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30" + ) + + +# --- throttling ------------------------------------------------------------------------------------ + +def throttle_check(db: GatewayDB, key: str) -> None: + if not key: + return + now = _now_dt() + with db.connect() as conn: + row = _one(db, conn, "SELECT failures, window_start FROM gateway_auth_throttle WHERE throttle_key = ?", (key,)) + if row and row["window_start"] >= _ts(now - timedelta(minutes=THROTTLE_WINDOW_MINUTES)): + if int(row["failures"]) >= THROTTLE_MAX_FAILURES: + raise AdminAuthError("too many failed sign-in attempts; try again in a few minutes", status_code=429) + + +def throttle_fail(db: GatewayDB, key: str) -> None: + if not key: + return + now = _now_dt() + with db.connect() as conn: + row = _one(db, conn, "SELECT failures, window_start FROM gateway_auth_throttle WHERE throttle_key = ?", (key,)) + if not row or row["window_start"] < _ts(now - timedelta(minutes=THROTTLE_WINDOW_MINUTES)): + db._execute(conn, "DELETE FROM gateway_auth_throttle WHERE throttle_key = ?", (key,)) + db._execute(conn, "INSERT INTO gateway_auth_throttle(throttle_key, failures, window_start) VALUES (?, 1, ?)", (key, _ts(now))) + else: + db._execute(conn, "UPDATE gateway_auth_throttle SET failures = failures + 1 WHERE throttle_key = ?", (key,)) + + +# --- accounts ---------------------------------------------------------------------------------------- + +def _public(row: dict[str, Any]) -> dict[str, Any]: + status = "disabled" if row.get("disabled_at") else "active" if int(row.get("active") or 0) else "setup_pending" + return { + "admin_id": row["admin_id"], + "email": row["email"], + "display_name": row.get("display_name") or "", + "role": row["role"], + "status": status, + "two_factor": bool(row.get("totp_secret")), + "created_at": row.get("created_at"), + "created_by": row.get("created_by") or "", + "last_login_at": row.get("last_login_at"), + "locked": bool(row.get("locked_until") and row["locked_until"] > _ts(_now_dt())), + } + + +def admin_count(db: GatewayDB) -> int: + with db.connect() as conn: + row = _one(db, conn, "SELECT COUNT(*) AS n FROM gateway_admins") + return int(row.get("n") or 0) + + +def list_admins(db: GatewayDB) -> list[dict[str, Any]]: + with db.connect() as conn: + rows = _all(db, conn, "SELECT * FROM gateway_admins ORDER BY created_at ASC") + return [_public(r) for r in rows] + + +def _issue_setup(db: GatewayDB, conn: Any, admin_id: str) -> tuple[str, str]: + token = SETUP_PREFIX + secrets.token_urlsafe(32) + expires = _ts(_now_dt() + timedelta(hours=SETUP_LINK_HOURS)) + db._execute( + conn, + "UPDATE gateway_admins SET setup_token_hash = ?, setup_expires_at = ?, totp_pending_secret = '' WHERE admin_id = ?", + (token_hash(token), expires, admin_id), + ) + return token, expires + + +def create_admin(db: GatewayDB, *, email: str, display_name: str, role: str, created_by: str) -> dict[str, Any]: + address = normalize_email(email) + if role not in ROLES: + raise AdminAuthError(f"role must be one of {', '.join(ROLES)}") + name = " ".join(str(display_name or "").split())[:120] + admin_id = "adm_" + uuid.uuid4().hex[:20] + with db.connect() as conn: + if _one(db, conn, "SELECT admin_id FROM gateway_admins WHERE email = ?", (address,)): + raise AdminAuthError("an administrator with this email already exists", status_code=409) + db._execute( + conn, + "INSERT INTO gateway_admins(admin_id, email, display_name, role, active, created_at, created_by) VALUES (?, ?, ?, ?, 0, ?, ?)", + (admin_id, address, name, role, _ts(_now_dt()), str(created_by or "")[:200]), + ) + token, expires = _issue_setup(db, conn, admin_id) + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + return {**_public(row), "setup_token": token, "setup_expires_at": expires} + + +def bootstrap_first_owner(db: GatewayDB, *, email: str, display_name: str) -> dict[str, Any]: + """Create the first owner. Only possible while no administrator exists.""" + if admin_count(db) > 0: + raise AdminAuthError("an administrator already exists; sign in with that account", status_code=409) + return create_admin(db, email=email, display_name=display_name, role="owner", created_by="bootstrap-token") + + +def _active_owner_count(db: GatewayDB, conn: Any, exclude: str = "") -> int: + row = _one( + db, conn, + "SELECT COUNT(*) AS n FROM gateway_admins WHERE role = 'owner' AND active = 1 AND disabled_at IS NULL AND admin_id <> ?", + (exclude,), + ) + return int(row.get("n") or 0) + + +def update_admin(db: GatewayDB, admin_id: str, *, role: str | None = None, disabled: bool | None = None) -> dict[str, Any]: + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + if not row: + raise AdminAuthError("administrator not found", status_code=404) + losing_owner = row["role"] == "owner" and ( + (role is not None and role != "owner") or disabled is True + ) + if losing_owner and _active_owner_count(db, conn, exclude=admin_id) == 0: + raise AdminAuthError("keep at least one active owner", status_code=409) + if role is not None: + if role not in ROLES: + raise AdminAuthError(f"role must be one of {', '.join(ROLES)}") + db._execute(conn, "UPDATE gateway_admins SET role = ? WHERE admin_id = ?", (role, admin_id)) + if disabled is True: + db._execute(conn, "UPDATE gateway_admins SET disabled_at = ? WHERE admin_id = ?", (_ts(_now_dt()), admin_id)) + db._execute(conn, "UPDATE gateway_admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL", (_ts(_now_dt()), admin_id)) + elif disabled is False: + db._execute(conn, "UPDATE gateway_admins SET disabled_at = NULL, failed_count = 0, locked_until = NULL WHERE admin_id = ?", (admin_id,)) + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + return _public(row) + + +def reset_admin(db: GatewayDB, admin_id: str) -> dict[str, Any]: + """Clear password and 2FA, end sessions and issue a new setup link.""" + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + if not row: + raise AdminAuthError("administrator not found", status_code=404) + if row["role"] == "owner" and _active_owner_count(db, conn, exclude=admin_id) == 0: + raise AdminAuthError("add another owner before resetting the only owner", status_code=409) + db._execute( + conn, + "UPDATE gateway_admins SET password_hash = '', totp_secret = '', totp_last_step = 0, active = 0, failed_count = 0, locked_until = NULL WHERE admin_id = ?", + (admin_id,), + ) + db._execute(conn, "UPDATE gateway_admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL", (_ts(_now_dt()), admin_id)) + token, expires = _issue_setup(db, conn, admin_id) + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + return {**_public(row), "setup_token": token, "setup_expires_at": expires} + + +def _setup_row(db: GatewayDB, conn: Any, setup_token: str) -> dict[str, Any]: + token = str(setup_token or "").strip() + if not token.startswith(SETUP_PREFIX): + raise AdminAuthError("this setup link is invalid", status_code=401) + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE setup_token_hash = ?", (token_hash(token),)) + if not row or row.get("disabled_at") or (row.get("setup_expires_at") or "") < _ts(_now_dt()): + raise AdminAuthError("this setup link is invalid or has expired; ask an owner for a new one", status_code=401) + return row + + +def start_setup(db: GatewayDB, setup_token: str) -> dict[str, Any]: + secret = new_totp_secret() + with db.connect() as conn: + row = _setup_row(db, conn, setup_token) + db._execute(conn, "UPDATE gateway_admins SET totp_pending_secret = ? WHERE admin_id = ?", (secret, row["admin_id"])) + return { + "email": row["email"], + "display_name": row.get("display_name") or "", + "role": row["role"], + "totp_secret": secret, + "otpauth_uri": otpauth_uri(secret, row["email"]), + "password_min_length": PASSWORD_MIN_LENGTH, + } + + +def complete_setup(db: GatewayDB, setup_token: str, *, password: str, totp_code: str) -> tuple[dict[str, Any], str]: + with db.connect() as conn: + row = _setup_row(db, conn, setup_token) + check_password_strength(password, row["email"]) + secret = row.get("totp_pending_secret") or "" + step = verify_totp(secret, totp_code) + if not secret or step is None: + raise AdminAuthError("the authenticator code did not match; check the time on your phone and try again") + db._execute( + conn, + "UPDATE gateway_admins SET password_hash = ?, totp_secret = ?, totp_pending_secret = '', totp_last_step = ?, " + "setup_token_hash = NULL, setup_expires_at = NULL, active = 1, failed_count = 0, locked_until = NULL, last_login_at = ? " + "WHERE admin_id = ?", + (hash_password(password), secret, step, _ts(_now_dt()), row["admin_id"]), + ) + identity = AdminIdentity(row["admin_id"], row["email"], row.get("display_name") or "", row["role"], "password_totp") + return _public({**row, "active": 1, "totp_secret": secret}), create_session(db, identity) + + +def login(db: GatewayDB, *, email: str, password: str, totp_code: str, source: str = "") -> tuple[AdminIdentity, str]: + throttle_check(db, f"ip:{source}") + generic = AdminAuthError("email, password or authenticator code is not correct", status_code=401) + try: + address = normalize_email(email) + except AdminAuthError: + throttle_fail(db, f"ip:{source}") + raise generic + now = _ts(_now_dt()) + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE email = ?", (address,)) + if not row or not int(row.get("active") or 0) or row.get("disabled_at"): + verify_password(password, hash_password("timing-equalizer")) # keep timing comparable + throttle_fail(db, f"ip:{source}") + raise generic + if row.get("locked_until") and row["locked_until"] > now: + raise AdminAuthError("this account is temporarily locked after failed attempts; try again later", status_code=423) + ok_password = verify_password(password, row.get("password_hash") or "") + step = verify_totp(row.get("totp_secret") or "", totp_code, last_step=int(row.get("totp_last_step") or 0)) if ok_password else None + if not ok_password or step is None: + # Record the failure first; the connection commits only on a clean exit. + failures = int(row.get("failed_count") or 0) + 1 + locked = _ts(_now_dt() + timedelta(minutes=LOCK_MINUTES)) if failures >= LOCK_AFTER_FAILURES else None + with db.connect() as conn: + db._execute( + conn, + "UPDATE gateway_admins SET failed_count = ?, locked_until = ? WHERE admin_id = ?", + (0 if locked else failures, locked, row["admin_id"]), + ) + throttle_fail(db, f"ip:{source}") + raise generic + with db.connect() as conn: + db._execute( + conn, + "UPDATE gateway_admins SET failed_count = 0, locked_until = NULL, totp_last_step = ?, last_login_at = ? WHERE admin_id = ?", + (step, now, row["admin_id"]), + ) + identity = AdminIdentity(row["admin_id"], row["email"], row.get("display_name") or "", row["role"], "password_totp") + return identity, create_session(db, identity) + + +def login_sso(db: GatewayDB, *, email: str) -> tuple[AdminIdentity, str]: + address = normalize_email(email) + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_admins WHERE email = ?", (address,)) + if not row or row.get("disabled_at"): + raise AdminAuthError("no administrator account for this email; ask an owner to add you", status_code=403) + # SSO proves identity; the organization's IdP enforces MFA. Activate on first SSO use. + db._execute( + conn, + "UPDATE gateway_admins SET active = 1, setup_token_hash = NULL, setup_expires_at = NULL, last_login_at = ? WHERE admin_id = ?", + (_ts(_now_dt()), row["admin_id"]), + ) + identity = AdminIdentity(row["admin_id"], row["email"], row.get("display_name") or "", row["role"], "sso") + return identity, create_session(db, identity) + + +# --- sessions ---------------------------------------------------------------------------------------- + +def create_session(db: GatewayDB, identity: AdminIdentity) -> str: + token = SESSION_PREFIX + secrets.token_urlsafe(32) + now = _now_dt() + with db.connect() as conn: + db._execute( + conn, + "INSERT INTO gateway_admin_sessions(session_hash, admin_id, auth_method, created_at, last_seen_at, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)", + (token_hash(token), identity.admin_id, identity.auth_method, _ts(now), _ts(now), _ts(now + timedelta(hours=SESSION_ABSOLUTE_HOURS))), + ) + return token + + +def validate_session(db: GatewayDB, token: str) -> AdminIdentity | None: + if not str(token or "").startswith(SESSION_PREFIX): + return None + now = _now_dt() + with db.connect() as conn: + row = _one( + db, conn, + """SELECT s.session_hash, s.auth_method, s.last_seen_at, s.expires_at, s.revoked_at, + a.admin_id, a.email, a.display_name, a.role, a.active, a.disabled_at + FROM gateway_admin_sessions s JOIN gateway_admins a ON a.admin_id = s.admin_id + WHERE s.session_hash = ?""", + (token_hash(token),), + ) + if not row or row.get("revoked_at") or row.get("disabled_at") or not int(row.get("active") or 0): + return None + if row["expires_at"] < _ts(now) or row["last_seen_at"] < _ts(now - timedelta(minutes=SESSION_IDLE_MINUTES)): + return None + db._execute(conn, "UPDATE gateway_admin_sessions SET last_seen_at = ? WHERE session_hash = ?", (_ts(now), row["session_hash"])) + return AdminIdentity(row["admin_id"], row["email"], row.get("display_name") or "", row["role"], row["auth_method"]) + + +def revoke_session(db: GatewayDB, token: str) -> None: + with db.connect() as conn: + db._execute(conn, "UPDATE gateway_admin_sessions SET revoked_at = ? WHERE session_hash = ? AND revoked_at IS NULL", (_ts(_now_dt()), token_hash(token))) diff --git a/gateway/admin_console.html b/gateway/admin_console.html index 29c99a63..17dae165 100644 --- a/gateway/admin_console.html +++ b/gateway/admin_console.html @@ -8,77 +8,173 @@
ADMIN

OpenWorkGraph · Organization Admin Console

- + -
+ + + + + + + +
diff --git a/gateway/db.py b/gateway/db.py index a12c69b2..fb5c9182 100644 --- a/gateway/db.py +++ b/gateway/db.py @@ -387,6 +387,13 @@ def audit( action: str, details: dict[str, Any] | None = None, ) -> None: + if principal_id == "gateway-admin": + # Record the named administrator behind a shared admin check. + from .admin_accounts import current_admin + + admin = current_admin() + if admin is not None: + principal_id = admin.audit_id with self.connect() as conn: self._execute( conn, diff --git a/gateway/employees.py b/gateway/employees.py new file mode 100644 index 00000000..96fb5d6a --- /dev/null +++ b/gateway/employees.py @@ -0,0 +1,694 @@ +from __future__ import annotations + +"""Employee roster, personal invitations and device identity. + +The roster ties every enrolled computer to a real work identity: + +* Administrators add employees (work email, name, teams) one by one or by CSV. +* A **personal invitation** is bound to one employee. A computer that joins with + it gets that employee's identity; nothing is typed by the employee. If the + invitation requires SSO, the employee must first confirm with the + organization's sign-in and the verified email must match the roster entry. +* Every device records *how* its identity was established: ``sso_verified``, + ``personal_invite``, ``admin_linked`` or ``self_reported`` (reusable link, + legacy code or managed file). Administrators can link a self-reported device + to a roster employee, optionally re-attributing its past evidence. +* An organization can require verified identity: then only personal + invitations can enroll new computers. +* Offboarding revokes the employee's device credentials and open invitations. + +An employee's ``actor_id`` is their normalized work email, which is also what +the Gateway stores on their evidence and what the /me page reads. +""" + +import csv +import io +import json +import re +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from shared.time_utils import normalize_timestamp +from .auth import DEVICE_SCOPES, issue_token, token_hash +from .db import GatewayDB + +PERSON_TOKEN_PREFIX = "owg_enroll_person" +MAX_INVITE_DAYS = 30 +MAX_DEVICES_PER_INVITE = 5 +MAX_IMPORT_ROWS = 5000 +IDENTITY_SOURCES = ("sso_verified", "personal_invite", "admin_linked", "self_reported") +_EMAIL_RE = re.compile(r"^[^@\s]{1,128}@[^@\s]{1,253}\.[^@\s]{2,63}$") +_TEAM_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS gateway_employees ( + employee_id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + actor_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + sso_subject TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + offboarded_at TEXT, + UNIQUE (organization_id, email), + UNIQUE (organization_id, actor_id) +); +CREATE TABLE IF NOT EXISTS gateway_personal_invites ( + invite_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + organization_id TEXT NOT NULL, + organization_name TEXT NOT NULL DEFAULT '', + employee_id TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, + max_devices INTEGER NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + require_sso INTEGER NOT NULL DEFAULT 0, + sso_verified_at TEXT, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_gateway_personal_invites_emp ON gateway_personal_invites(organization_id, employee_id); +CREATE TABLE IF NOT EXISTS gateway_device_identity ( + organization_id TEXT NOT NULL, + device_id TEXT NOT NULL, + employee_id TEXT, + identity_source TEXT NOT NULL, + linked_at TEXT NOT NULL, + linked_by TEXT NOT NULL DEFAULT '', + PRIMARY KEY (organization_id, device_id) +); +CREATE TABLE IF NOT EXISTS gateway_org_settings ( + organization_id TEXT PRIMARY KEY, + require_verified_identity INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +) +""" + + +class EmployeeError(ValueError): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +def _now() -> str: + return normalize_timestamp(datetime.now(timezone.utc).isoformat()) + + +def _one(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any]: + cur = db._execute(conn, sql, params) + row = cur.fetchone() + columns = [d[0] for d in cur.description] if cur.description else None + return db._row(row, columns) + + +def _all(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + cur = db._execute(conn, sql, params) + rows = cur.fetchall() + columns = [d[0] for d in cur.description] if cur.description else None + return [db._row(r, columns) for r in rows] + + +def init_employee_schema(db: GatewayDB) -> None: + with db.connect() as conn: + for statement in [x.strip() for x in SCHEMA.split(";") if x.strip()]: + conn.execute(statement) + + +def _org(value: str) -> str: + org = str(value or "").strip() + if not org or len(org) > 512: + raise EmployeeError("organization_id must be 1-512 characters") + return org + + +def normalize_email(value: str) -> str: + email = str(value or "").strip().lower() + if not _EMAIL_RE.fullmatch(email): + raise EmployeeError(f"not a valid work email: {str(value)[:80]!r}") + return email + + +def _teams(value: Any) -> list[str]: + if isinstance(value, str): + value = re.split(r"[;,|]", value) + teams = sorted({str(t).strip() for t in (value or []) if str(t).strip()}) + bad = [t for t in teams if not _TEAM_RE.fullmatch(t)] + if bad: + raise EmployeeError(f"team names may use letters, digits, '.', '_' and '-': {bad[:3]}") + return teams + + +# --- organization settings --------------------------------------------------------------- + +def org_settings(db: GatewayDB, organization_id: str) -> dict[str, Any]: + org = _org(organization_id) + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_org_settings WHERE organization_id = ?", (org,)) + return { + "organization_id": org, + "require_verified_identity": bool(int(row.get("require_verified_identity") or 0)) if row else False, + "updated_at": row.get("updated_at") if row else None, + } + + +def set_org_settings(db: GatewayDB, organization_id: str, *, require_verified_identity: bool) -> dict[str, Any]: + org = _org(organization_id) + with db.connect() as conn: + db._execute(conn, "DELETE FROM gateway_org_settings WHERE organization_id = ?", (org,)) + db._execute( + conn, + "INSERT INTO gateway_org_settings(organization_id, require_verified_identity, updated_at) VALUES (?, ?, ?)", + (org, 1 if require_verified_identity else 0, _now()), + ) + return org_settings(db, org) + + +# --- roster ------------------------------------------------------------------------------------ + +def _employee_public(row: dict[str, Any]) -> dict[str, Any]: + return { + "employee_id": row["employee_id"], + "organization_id": row["organization_id"], + "email": row["email"], + "display_name": row.get("display_name") or "", + "actor_id": row["actor_id"], + "status": row.get("status") or "active", + "sso_linked": bool(row.get("sso_subject")), + "created_at": row.get("created_at"), + "offboarded_at": row.get("offboarded_at"), + } + + +def get_employee(db: GatewayDB, organization_id: str, employee_id: str) -> dict[str, Any]: + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_employees WHERE organization_id = ? AND employee_id = ?", (_org(organization_id), employee_id)) + if not row: + raise EmployeeError("employee not found", status_code=404) + return _employee_public(row) + + +def employee_by_email(db: GatewayDB, organization_id: str | None, email: str) -> list[dict[str, Any]]: + address = normalize_email(email) + with db.connect() as conn: + if organization_id: + rows = _all(db, conn, "SELECT * FROM gateway_employees WHERE organization_id = ? AND email = ? AND status = 'active'", (_org(organization_id), address)) + else: + rows = _all(db, conn, "SELECT * FROM gateway_employees WHERE email = ? AND status = 'active' ORDER BY organization_id", (address,)) + return [_employee_public(r) for r in rows] + + +def employee_by_actor(db: GatewayDB, organization_id: str, actor_id: str) -> dict[str, Any] | None: + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_employees WHERE organization_id = ? AND actor_id = ?", (_org(organization_id), str(actor_id or ""))) + return _employee_public(row) if row else None + + +def upsert_employee( + db: GatewayDB, + organization_id: str, + *, + email: str, + display_name: str = "", + teams: Any = None, +) -> tuple[dict[str, Any], bool]: + """Create or update an employee. Returns (employee, created).""" + from .human_access import set_actor_teams + + org = _org(organization_id) + address = normalize_email(email) + name = " ".join(str(display_name or "").split())[:120] + team_list = _teams(teams) if teams is not None else None + now = _now() + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_employees WHERE organization_id = ? AND email = ?", (org, address)) + created = not row + if created: + employee_id = "emp_" + uuid.uuid4().hex[:20] + db._execute( + conn, + "INSERT INTO gateway_employees(employee_id, organization_id, email, display_name, actor_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", + (employee_id, org, address, name, address, now, now), + ) + else: + employee_id = row["employee_id"] + db._execute( + conn, + "UPDATE gateway_employees SET display_name = CASE WHEN ? <> '' THEN ? ELSE display_name END, status = 'active', offboarded_at = NULL, updated_at = ? WHERE employee_id = ?", + (name, name, now, employee_id), + ) + if team_list is not None: + set_actor_teams(db, org, address, team_list) + return get_employee(db, org, employee_id), created + + +def list_employees(db: GatewayDB, organization_id: str, *, include_offboarded: bool = False) -> list[dict[str, Any]]: + from .human_access import actor_teams + + org = _org(organization_id) + with db.connect() as conn: + rows = _all( + db, conn, + "SELECT * FROM gateway_employees WHERE organization_id = ?" + ("" if include_offboarded else " AND status = 'active'") + " ORDER BY email", + (org,), + ) + invites = _all( + db, conn, + "SELECT employee_id, COUNT(*) AS n FROM gateway_personal_invites WHERE organization_id = ? AND revoked_at IS NULL AND expires_at >= ? AND use_count < max_devices GROUP BY employee_id", + (org, _now()), + ) + open_invites = {r["employee_id"]: int(r["n"]) for r in invites} + people = [] + for row in rows: + item = _employee_public(row) + item["teams"] = sorted(actor_teams(db, org, row["actor_id"])) + item["open_invites"] = open_invites.get(row["employee_id"], 0) + people.append(item) + return people + + +def import_csv(db: GatewayDB, organization_id: str, text: str) -> dict[str, Any]: + """CSV columns: email (required), name, teams (separated by ';').""" + raw = str(text or "") + if len(raw) > 2_000_000: + raise EmployeeError("the CSV is too large (max 2 MB)") + reader = csv.reader(io.StringIO(raw)) + # Keep the real file line numbers so reported errors point at the right line. + numbered = [(reader.line_num, r) for r in reader if any(cell.strip() for cell in r)] + if not numbered: + raise EmployeeError("the CSV is empty") + rows = [r for _n, r in numbered] + header = [c.strip().lower() for c in rows[0]] + has_header = "email" in header + idx = { + "email": header.index("email") if has_header else 0, + "name": header.index("name") if has_header and "name" in header else (1 if not has_header else -1), + "teams": header.index("teams") if has_header and "teams" in header else (2 if not has_header else -1), + } + body = numbered[1:] if has_header else numbered + if len(body) > MAX_IMPORT_ROWS: + raise EmployeeError(f"import at most {MAX_IMPORT_ROWS} employees at a time") + created = updated = 0 + errors: list[dict[str, Any]] = [] + changed: list[dict[str, Any]] = [] + for number, row in body: + def cell(key: str) -> str: + i = idx[key] + return row[i].strip() if 0 <= i < len(row) else "" + try: + teams = cell("teams") + employee, was_created = upsert_employee( + db, organization_id, email=cell("email"), display_name=cell("name"), + teams=teams if idx["teams"] >= 0 else None, + ) + changed.append({"actor_id": employee["actor_id"], "created": was_created}) + created += int(was_created) + updated += int(not was_created) + except EmployeeError as exc: + errors.append({"line": number, "error": str(exc)}) + return {"created": created, "updated": updated, "errors": errors[:200], "error_count": len(errors), "changed": changed} + + +def offboard_employee(db: GatewayDB, organization_id: str, employee_id: str) -> dict[str, Any]: + from .human_access import set_actor_teams + + org = _org(organization_id) + employee = get_employee(db, org, employee_id) + now = _now() + with db.connect() as conn: + devices = _all( + db, conn, + "SELECT DISTINCT device_id FROM access_tokens WHERE organization_id = ? AND token_type = 'device' AND actor_id = ? AND revoked_at IS NULL", + (org, employee["actor_id"]), + ) + db._execute( + conn, + "UPDATE access_tokens SET revoked_at = ? WHERE organization_id = ? AND token_type = 'device' AND actor_id = ? AND revoked_at IS NULL", + (now, org, employee["actor_id"]), + ) + db._execute( + conn, + "UPDATE gateway_personal_invites SET revoked_at = ? WHERE organization_id = ? AND employee_id = ? AND revoked_at IS NULL", + (now, org, employee_id), + ) + db._execute( + conn, + "UPDATE gateway_employees SET status = 'offboarded', offboarded_at = ?, updated_at = ? WHERE employee_id = ?", + (now, now, employee_id), + ) + set_actor_teams(db, org, employee["actor_id"], []) + return { + **get_employee(db, org, employee_id), + "revoked_devices": [d["device_id"] for d in devices], + "evidence_deleted": False, + } + + +# --- personal invitations -------------------------------------------------------------------- + +def create_personal_invite( + db: GatewayDB, + organization_id: str, + employee_id: str, + *, + organization_name: str = "", + expires_days: int = 7, + max_devices: int = 2, + require_sso: bool = False, + created_by: str = "", +) -> dict[str, Any]: + org = _org(organization_id) + employee = get_employee(db, org, employee_id) + if employee["status"] != "active": + raise EmployeeError("this employee is offboarded; add them again first", status_code=409) + days = max(1, min(int(expires_days), MAX_INVITE_DAYS)) + devices = max(1, min(int(max_devices), MAX_DEVICES_PER_INVITE)) + token = issue_token(PERSON_TOKEN_PREFIX) + invite_id = "pinv_" + token_hash(token)[:20] + now = datetime.now(timezone.utc) + expires = normalize_timestamp((now + timedelta(days=days)).isoformat()) + with db.connect() as conn: + db._execute( + conn, + """INSERT INTO gateway_personal_invites(invite_id, token_hash, organization_id, organization_name, employee_id, + created_at, created_by, expires_at, max_devices, use_count, require_sso, sso_verified_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL)""", + (invite_id, token_hash(token), org, str(organization_name or org).strip()[:120] or org, employee_id, + normalize_timestamp(now.isoformat()), str(created_by or "")[:200], expires, devices, 1 if require_sso else 0), + ) + return { + "invite_id": invite_id, + "token": token, + "organization_id": org, + "employee": employee, + "expires_at": expires, + "max_devices": devices, + "require_sso": bool(require_sso), + } + + +def list_personal_invites(db: GatewayDB, organization_id: str, *, employee_id: str = "") -> list[dict[str, Any]]: + org = _org(organization_id) + with db.connect() as conn: + sql = """SELECT i.*, e.email, e.display_name FROM gateway_personal_invites i + JOIN gateway_employees e ON e.employee_id = i.employee_id + WHERE i.organization_id = ?""" + params: tuple[Any, ...] = (org,) + if employee_id: + sql += " AND i.employee_id = ?" + params = (org, employee_id) + rows = _all(db, conn, sql + " ORDER BY i.created_at DESC LIMIT 500", params) + now = _now() + out = [] + for r in rows: + state = ( + "revoked" if r.get("revoked_at") else + "used" if int(r["use_count"]) >= int(r["max_devices"]) else + "expired" if r["expires_at"] < now else "open" + ) + out.append({ + "invite_id": r["invite_id"], "employee_id": r["employee_id"], "email": r["email"], + "display_name": r.get("display_name") or "", "created_at": r["created_at"], + "created_by": r.get("created_by") or "", "expires_at": r["expires_at"], + "max_devices": int(r["max_devices"]), "use_count": int(r["use_count"]), + "require_sso": bool(int(r["require_sso"])), "sso_verified": bool(r.get("sso_verified_at")), + "state": state, + }) + return out + + +def revoke_personal_invite(db: GatewayDB, organization_id: str, invite_id: str) -> bool: + with db.connect() as conn: + cur = db._execute( + conn, + "UPDATE gateway_personal_invites SET revoked_at = ? WHERE organization_id = ? AND invite_id = ? AND revoked_at IS NULL", + (_now(), _org(organization_id), invite_id), + ) + return bool(cur.rowcount) + + +def _invite_row(db: GatewayDB, conn: Any, token: str, *, lock: bool = False) -> dict[str, Any]: + sql = """SELECT i.*, e.email, e.display_name, e.actor_id, e.status AS employee_status + FROM gateway_personal_invites i JOIN gateway_employees e ON e.employee_id = i.employee_id + WHERE i.token_hash = ?""" + if lock and db.is_postgres: + sql += " FOR UPDATE OF i" + return _one(db, conn, sql, (token_hash(str(token or "")),)) + + +def _usable(row: dict[str, Any]) -> bool: + return bool( + row and not row.get("revoked_at") and row.get("employee_status") == "active" + and row["expires_at"] >= _now() and int(row["use_count"]) < int(row["max_devices"]) + ) + + +def peek_personal_invite(db: GatewayDB, token: str) -> dict[str, Any] | None: + if not str(token or "").startswith(PERSON_TOKEN_PREFIX + "_"): + return None + with db.connect() as conn: + row = _invite_row(db, conn, token) + if not _usable(row): + return None + return { + "invite_id": row["invite_id"], + "organization_id": row["organization_id"], + "organization_name": row.get("organization_name") or row["organization_id"], + "employee_id": row["employee_id"], + "email": row["email"], + "display_name": row.get("display_name") or "", + "actor_id": row["actor_id"], + "expires_at": row["expires_at"], + "devices_left": int(row["max_devices"]) - int(row["use_count"]), + "require_sso": bool(int(row["require_sso"])), + "sso_verified": bool(row.get("sso_verified_at")), + } + + +def mark_invite_sso_verified(db: GatewayDB, token: str, *, verified_email: str, subject: str) -> dict[str, Any]: + """Record that the invited employee proved their identity with the organization's SSO.""" + email = normalize_email(verified_email) + with db.connect() as conn: + row = _invite_row(db, conn, token) + if not _usable(row): + raise EmployeeError("this invitation is no longer valid; ask your IT admin for a new one", status_code=401) + if row["email"] != email: + raise EmployeeError( + f"you signed in as {email}, but this invitation is for {row['email']}. Sign in with that account, or ask your IT admin.", + status_code=403, + ) + now = _now() + db._execute(conn, "UPDATE gateway_personal_invites SET sso_verified_at = ? WHERE invite_id = ?", (now, row["invite_id"])) + db._execute(conn, "UPDATE gateway_employees SET sso_subject = ?, updated_at = ? WHERE employee_id = ?", (str(subject)[:512], now, row["employee_id"])) + return peek_personal_invite(db, token) or {} + + +def mark_invite_verified_by_id(db: GatewayDB, invite_id: str, *, verified_email: str, subject: str) -> dict[str, Any]: + """SSO callback variant: the invitation is identified by id stored in the sign-in state.""" + email = normalize_email(verified_email) + now = _now() + with db.connect() as conn: + row = _one( + db, conn, + """SELECT i.*, e.email, e.display_name, e.actor_id, e.status AS employee_status + FROM gateway_personal_invites i JOIN gateway_employees e ON e.employee_id = i.employee_id + WHERE i.invite_id = ?""", + (str(invite_id or ""),), + ) + if not _usable(row): + raise EmployeeError("this invitation is no longer valid; ask your IT admin for a new one", status_code=401) + if row["email"] != email: + raise EmployeeError( + f"you signed in as {email}, but this invitation is for {row['email']}. Sign in with that account, or ask your IT admin.", + status_code=403, + ) + db._execute(conn, "UPDATE gateway_personal_invites SET sso_verified_at = ? WHERE invite_id = ?", (now, row["invite_id"])) + db._execute(conn, "UPDATE gateway_employees SET sso_subject = ?, updated_at = ? WHERE employee_id = ?", (str(subject)[:512], now, row["employee_id"])) + return { + "invite_id": row["invite_id"], "organization_id": row["organization_id"], + "email": row["email"], "actor_id": row["actor_id"], + } + + +def enroll_with_personal_invite(db: GatewayDB, *, token: str, requested_organization_id: str, device_id: str) -> dict[str, Any]: + """Consume one device slot and create the device credential in one transaction.""" + device = str(device_id or "").strip() + if not device or len(device) > 512: + raise EmployeeError("device_id is required", status_code=400) + device_token = issue_token("owg_device") + token_id = f"device_{uuid.uuid4().hex}" + now = _now() + with db.connect() as conn: + row = _invite_row(db, conn, token, lock=True) + if not _usable(row): + raise EmployeeError("this invitation is invalid, expired, revoked or already used; ask your IT admin for a new one", status_code=401) + org = row["organization_id"] + if requested_organization_id and str(requested_organization_id).strip() != org: + raise EmployeeError("this invitation belongs to a different organization", status_code=403) + if int(row["require_sso"]) and not row.get("sso_verified_at"): + raise EmployeeError("confirm your identity with your company sign-in first, then join", status_code=403) + existing = _one( + db, conn, + "SELECT token_id FROM access_tokens WHERE organization_id = ? AND token_type = 'device' AND device_id = ? AND revoked_at IS NULL", + (org, device), + ) + if existing: + raise EmployeeError("this computer is already enrolled; disconnect it first", status_code=409) + cur = db._execute( + conn, + "UPDATE gateway_personal_invites SET use_count = use_count + 1 WHERE invite_id = ? AND use_count < max_devices AND revoked_at IS NULL", + (row["invite_id"],), + ) + if not cur.rowcount: + raise EmployeeError("this invitation was just used up; ask your IT admin for a new one", status_code=401) + db._execute( + conn, + """INSERT INTO access_tokens(token_id, token_hash, token_type, organization_id, actor_id, device_id, scopes_json, created_at, revoked_at) + VALUES (?, ?, 'device', ?, ?, ?, ?, ?, NULL)""", + (token_id, token_hash(device_token), org, row["actor_id"], device, json.dumps(sorted(DEVICE_SCOPES)), now), + ) + source = "sso_verified" if row.get("sso_verified_at") else "personal_invite" + db._execute(conn, "DELETE FROM gateway_device_identity WHERE organization_id = ? AND device_id = ?", (org, device)) + db._execute( + conn, + "INSERT INTO gateway_device_identity(organization_id, device_id, employee_id, identity_source, linked_at, linked_by) VALUES (?, ?, ?, ?, ?, ?)", + (org, device, row["employee_id"], source, now, row["invite_id"]), + ) + return { + "organization_id": org, + "actor_id": row["actor_id"], + "device_id": device, + "token_id": token_id, + # Same key as every other enrollment response: the local connector reads "token". + "token": device_token, + "scopes": sorted(DEVICE_SCOPES), + "identity_source": source, + "employee": {"email": row["email"], "display_name": row.get("display_name") or ""}, + "invite_id": row["invite_id"], + } + + +# --- device identity --------------------------------------------------------------------------------- + +def record_self_reported(db: GatewayDB, organization_id: str, device_id: str, *, linked_by: str) -> None: + org = _org(organization_id) + with db.connect() as conn: + db._execute(conn, "DELETE FROM gateway_device_identity WHERE organization_id = ? AND device_id = ?", (org, device_id)) + db._execute( + conn, + "INSERT INTO gateway_device_identity(organization_id, device_id, employee_id, identity_source, linked_at, linked_by) VALUES (?, ?, NULL, 'self_reported', ?, ?)", + (org, device_id, _now(), str(linked_by or "")[:200]), + ) + + +def link_device( + db: GatewayDB, + organization_id: str, + device_id: str, + employee_id: str, + *, + reattribute_history: bool, + linked_by: str, +) -> dict[str, Any]: + org = _org(organization_id) + employee = get_employee(db, org, employee_id) + if employee["status"] != "active": + raise EmployeeError("this employee is offboarded", status_code=409) + now = _now() + with db.connect() as conn: + tokens = _all( + db, conn, + "SELECT token_id, actor_id FROM access_tokens WHERE organization_id = ? AND token_type = 'device' AND device_id = ? AND revoked_at IS NULL", + (org, device_id), + ) + if not tokens: + raise EmployeeError("no active computer with this device id", status_code=404) + previous = sorted({t["actor_id"] for t in tokens}) + db._execute( + conn, + "UPDATE access_tokens SET actor_id = ? WHERE organization_id = ? AND token_type = 'device' AND device_id = ? AND revoked_at IS NULL", + (employee["actor_id"], org, device_id), + ) + moved = 0 + if reattribute_history: + cur = db._execute( + conn, + "UPDATE evidence_events SET actor_id = ? WHERE organization_id = ? AND device_id = ? AND actor_id <> ?", + (employee["actor_id"], org, device_id, employee["actor_id"]), + ) + moved = int(cur.rowcount or 0) + db._execute(conn, "DELETE FROM gateway_device_identity WHERE organization_id = ? AND device_id = ?", (org, device_id)) + db._execute( + conn, + "INSERT INTO gateway_device_identity(organization_id, device_id, employee_id, identity_source, linked_at, linked_by) VALUES (?, ?, ?, 'admin_linked', ?, ?)", + (org, device_id, employee_id, now, str(linked_by or "")[:200]), + ) + return { + "organization_id": org, "device_id": device_id, "employee": employee, + "previous_actor_ids": previous, "reattributed_events": moved, + } + + +def people_and_devices(db: GatewayDB, organization_id: str) -> dict[str, Any]: + """Employees with their computers, plus computers not linked to anyone.""" + org = _org(organization_id) + with db.connect() as conn: + devices = _all( + db, conn, + """SELECT t.device_id, t.actor_id, t.created_at, t.token_id, d.employee_id, d.identity_source, d.linked_at + FROM access_tokens t LEFT JOIN gateway_device_identity d + ON d.organization_id = t.organization_id AND d.device_id = t.device_id + WHERE t.organization_id = ? AND t.token_type = 'device' AND t.revoked_at IS NULL + ORDER BY t.created_at DESC""", + (org,), + ) + activity = _all( + db, conn, + "SELECT device_id, COUNT(*) AS events, MAX(ingested_at) AS last_evidence FROM evidence_events WHERE organization_id = ? GROUP BY device_id", + (org,), + ) + by_device = {a["device_id"]: a for a in activity} + employees = list_employees(db, org) + by_actor = {e["actor_id"]: e for e in employees} + by_id = {e["employee_id"]: e for e in employees} + for e in employees: + e["devices"] = [] + unlinked = [] + for d in devices: + act = by_device.get(d["device_id"]) or {} + item = { + "device_id": d["device_id"], + "actor_id": d["actor_id"], + "joined_at": d["created_at"], + "last_evidence_at": act.get("last_evidence"), + "events": int(act.get("events") or 0), + "identity_source": d.get("identity_source") or "self_reported", + } + owner = by_id.get(d.get("employee_id") or "") or by_actor.get(d["actor_id"]) + if owner and item["identity_source"] != "self_reported": + owner["devices"].append(item) + elif owner: + # Self-reported identity that happens to match a roster email: shown, but flagged. + item["identity_note"] = "self-reported identity matches this employee's email" + owner["devices"].append(item) + else: + unlinked.append(item) + return { + "organization_id": org, + "employees": employees, + "unlinked_devices": unlinked, + "settings": org_settings(db, org), + } + + +def device_identity(db: GatewayDB, organization_id: str, device_id: str) -> dict[str, Any]: + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_device_identity WHERE organization_id = ? AND device_id = ?", (organization_id, device_id)) + return { + "identity_source": (row.get("identity_source") if row else None) or "self_reported", + "employee_id": row.get("employee_id") if row else None, + } diff --git a/gateway/enterprise_app.py b/gateway/enterprise_app.py index 6549269f..e860c4c4 100644 --- a/gateway/enterprise_app.py +++ b/gateway/enterprise_app.py @@ -14,6 +14,8 @@ SlidingWindowRateLimiter, bearer_fingerprint, ) +from .identity_hardening import install_identity_security_hardening +from .identity_routes import install_identity from .organization_admin import install_organization_admin from .settings import PRODUCT_VERSION, GatewaySettings @@ -57,6 +59,8 @@ def create_enterprise_app( app.state.rate_limiter = SlidingWindowRateLimiter(window_seconds=60) install_declared_policy_distribution(app, db=db, settings=settings) install_organization_admin(app, db=db, settings=settings) + install_identity(app, db=db, settings=settings) + install_identity_security_hardening() core_health = _take_get_endpoint(app, "/health") core_capabilities = _take_get_endpoint(app, "/v1/capabilities") @@ -94,7 +98,7 @@ def capabilities() -> dict[str, Any]: @app.middleware("http") async def credential_rate_limit(request: Request, call_next): path = request.url.path - if path in {"/health", "/v1/capabilities", "/openapi.json", "/docs", "/redoc", "/admin"}: + if path in {"/health", "/v1/capabilities", "/openapi.json", "/docs", "/redoc", "/admin", "/", "/me", "/join/verify", "/sso/callback"}: return await call_next(request) if path == "/v1/devices/enroll": diff --git a/gateway/human_enterprise_app.py b/gateway/human_enterprise_app.py index c4081f06..6f466893 100644 --- a/gateway/human_enterprise_app.py +++ b/gateway/human_enterprise_app.py @@ -8,6 +8,7 @@ from .enterprise_app import _take_get_endpoint, create_enterprise_app from .hardening import HardeningSettings, PooledGatewayDB from .human_access import HumanAccessSettings, install_human_access +from .identity_routes import install_roster_mapping from .settings import PRODUCT_VERSION, GatewaySettings GATEWAY_VERSION = PRODUCT_VERSION @@ -51,6 +52,8 @@ def require_admin(authorization: str | None) -> None: verifier=verifier, ) + install_roster_mapping(app, db) + previous_health = _take_get_endpoint(app, "/health") previous_capabilities = _take_get_endpoint(app, "/v1/capabilities") previous_runtime = _take_get_endpoint(app, "/v1/admin/runtime") diff --git a/gateway/identity_hardening.py b/gateway/identity_hardening.py new file mode 100644 index 00000000..465c1c50 --- /dev/null +++ b/gateway/identity_hardening.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +"""Security hardening for Gateway identity/admin flows. + +Kept separate from the route layer so the invariants are easy to test: + +* the bootstrap credential can recover the sole owner when API bootstrap access + has deliberately been enabled; +* an SSO confirmation on a personal invitation authorizes exactly one computer + enrollment, even when the invitation itself allows several computers; +* company sign-in to /me never guesses between two organizations that contain + the same work email. +""" + +import json +import uuid +from typing import Any + +from . import admin_accounts as accounts +from . import employees as roster +from . import me_access + +_INSTALLED = False + + +def _bootstrap_reset_admin(db: Any, admin_id: str) -> dict[str, Any]: + """Reset an admin, including the sole owner, under bootstrap recovery.""" + with db.connect() as conn: + row = accounts._one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + if not row: + raise accounts.AdminAuthError("administrator not found", status_code=404) + now = accounts._ts(accounts._now_dt()) + db._execute( + conn, + "UPDATE gateway_admins SET password_hash = '', totp_secret = '', totp_pending_secret = '', " + "totp_last_step = 0, active = 0, failed_count = 0, locked_until = NULL WHERE admin_id = ?", + (admin_id,), + ) + db._execute( + conn, + "UPDATE gateway_admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL", + (now, admin_id), + ) + token, expires = accounts._issue_setup(db, conn, admin_id) + row = accounts._one(db, conn, "SELECT * FROM gateway_admins WHERE admin_id = ?", (admin_id,)) + return {**accounts._public(row), "setup_token": token, "setup_expires_at": expires} + + +def _secure_personal_enroll( + db: Any, + *, + token: str, + requested_organization_id: str, + device_id: str, +) -> dict[str, Any]: + """Consume one invite slot and, when required, one SSO proof atomically.""" + device = str(device_id or "").strip() + if not device or len(device) > 512: + raise roster.EmployeeError("device_id is required", status_code=400) + + device_token = roster.issue_token("owg_device") + token_id = f"device_{uuid.uuid4().hex}" + now = roster._now() + + with db.connect() as conn: + row = roster._invite_row(db, conn, token, lock=True) + if not roster._usable(row): + raise roster.EmployeeError( + "this invitation is invalid, expired, revoked or already used; ask your IT admin for a new one", + status_code=401, + ) + org = row["organization_id"] + if requested_organization_id and str(requested_organization_id).strip() != org: + raise roster.EmployeeError("this invitation belongs to a different organization", status_code=403) + + requires_sso = bool(int(row["require_sso"])) + if requires_sso and not row.get("sso_verified_at"): + raise roster.EmployeeError( + "confirm your identity with your company sign-in first, then join", + status_code=403, + ) + + existing = roster._one( + db, + conn, + "SELECT token_id FROM access_tokens WHERE organization_id = ? AND token_type = 'device' " + "AND device_id = ? AND revoked_at IS NULL", + (org, device), + ) + if existing: + raise roster.EmployeeError("this computer is already enrolled; disconnect it first", status_code=409) + + # The conditional also protects SQLite/concurrent callers that observed + # the same verification before either writer acquired the write lock. + cur = db._execute( + conn, + "UPDATE gateway_personal_invites " + "SET use_count = use_count + 1, " + "sso_verified_at = CASE WHEN require_sso = 1 THEN NULL ELSE sso_verified_at END " + "WHERE invite_id = ? AND use_count < max_devices AND revoked_at IS NULL " + "AND (require_sso = 0 OR sso_verified_at IS NOT NULL)", + (row["invite_id"],), + ) + if not cur.rowcount: + raise roster.EmployeeError( + "this invitation was just used or its identity confirmation was already consumed; confirm again", + status_code=401, + ) + + db._execute( + conn, + "INSERT INTO access_tokens(token_id, token_hash, token_type, organization_id, actor_id, device_id, scopes_json, created_at, revoked_at) " + "VALUES (?, ?, 'device', ?, ?, ?, ?, ?, NULL)", + ( + token_id, + roster.token_hash(device_token), + org, + row["actor_id"], + device, + json.dumps(sorted(roster.DEVICE_SCOPES)), + now, + ), + ) + source = "sso_verified" if requires_sso else "personal_invite" + db._execute( + conn, + "DELETE FROM gateway_device_identity WHERE organization_id = ? AND device_id = ?", + (org, device), + ) + db._execute( + conn, + "INSERT INTO gateway_device_identity(organization_id, device_id, employee_id, identity_source, linked_at, linked_by) " + "VALUES (?, ?, ?, ?, ?, ?)", + (org, device, row["employee_id"], source, now, row["invite_id"]), + ) + + return { + "organization_id": org, + "actor_id": row["actor_id"], + "device_id": device, + "token_id": token_id, + "token": device_token, + "scopes": sorted(roster.DEVICE_SCOPES), + "identity_source": source, + "employee": {"email": row["email"], "display_name": row.get("display_name") or ""}, + "invite_id": row["invite_id"], + } + + +def _unambiguous_sso_session( + db: Any, + *, + email: str, + organization_id: str = "", +) -> tuple[str, dict[str, Any]]: + matches = roster.employee_by_email(db, organization_id or None, email) + if not matches: + raise me_access.MeError( + f"{email} is not in your organization's OpenWorkGraph roster; ask your IT admin", + status_code=403, + ) + if not organization_id and len(matches) != 1: + raise me_access.MeError( + "this company account belongs to more than one organization on this Gateway; " + "open /me from your enrolled OpenWorkGraph computer so the organization is unambiguous", + status_code=409, + ) + employee = matches[0] + session = me_access._create_session( + db, + organization_id=employee["organization_id"], + actor_id=employee["actor_id"], + auth_method="sso", + ) + return session, {"organization_id": employee["organization_id"], "actor_id": employee["actor_id"]} + + +def install_identity_security_hardening() -> None: + """Install the hardened implementations once per process.""" + global _INSTALLED + if _INSTALLED: + return + + original_reset = accounts.reset_admin + + def reset_admin(db: Any, admin_id: str) -> dict[str, Any]: + actor = accounts.current_admin() + if actor is not None and actor.admin_id == "bootstrap": + return _bootstrap_reset_admin(db, admin_id) + return original_reset(db, admin_id) + + accounts.reset_admin = reset_admin + roster.enroll_with_personal_invite = _secure_personal_enroll + me_access.sso_session = _unambiguous_sso_session + _INSTALLED = True + + +__all__ = ["install_identity_security_hardening"] diff --git a/gateway/identity_routes.py b/gateway/identity_routes.py new file mode 100644 index 00000000..ced10148 --- /dev/null +++ b/gateway/identity_routes.py @@ -0,0 +1,764 @@ +from __future__ import annotations + +"""Named administrators, employee roster, company sign-in and the /me page. + +Installed into the enterprise Gateway after the organization admin console. + +Admin requests +-------------- +Every existing admin endpoint still checks the Gateway admin credential. The +``AdminIdentityMiddleware`` lets named administrators use them: it validates an +``owg_admin_session_...`` bearer, enforces the role (viewers are read-only; +managing administrators needs an owner), records who is acting for the audit +log, and only then presents the request to the existing check. The bootstrap +token keeps working for scripts unless ``OWG_GATEWAY_ADMIN_TOKEN_API=disabled``. +""" + +import dataclasses +import json +import os +import secrets +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote + +import anyio +from fastapi import FastAPI, Header, HTTPException, Query, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse +from pydantic import BaseModel, Field + +from shared.lifespan import extend_lifespan +from . import admin_accounts as accounts +from . import employees as roster +from . import me_access +from .app import EnrollmentRequest +from .auth import env_token_matches, token_hash +from .db import GatewayDB +from .enrollment_links import peek_enrollment_link +from .settings import GatewaySettings +from .sso import SSOClient, SSOError, SSOSettings, init_sso_schema + +_HERE = Path(__file__).resolve().parent +_READ_METHODS = {"GET", "HEAD", "OPTIONS"} + + +def _bearer(value: str | None) -> str: + raw = str(value or "") + return raw[7:].strip() if raw.lower().startswith("bearer ") else "" + + +def _error(exc: Exception) -> HTTPException: + return HTTPException(status_code=getattr(exc, "status_code", 400), detail=str(exc)) + + +# --- middleware --------------------------------------------------------------------------- + +class AdminIdentityMiddleware: + """Pure ASGI middleware so the admin identity is visible to sync endpoints.""" + + def __init__(self, app: Any, *, db: GatewayDB, settings: GatewaySettings) -> None: + self.app = app + self.db = db + self.settings = settings + + async def _reject(self, send: Callable, status: int, detail: str) -> None: + body = json.dumps({"detail": detail}).encode("utf-8") + await send({"type": "http.response.start", "status": status, "headers": [ + (b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()), + (b"cache-control", b"no-store"), + ]}) + await send({"type": "http.response.body", "body": body}) + + async def __call__(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + path = str(scope.get("path") or "") + if scope.get("type") != "http" or not path.startswith("/v1/admin/") or path.startswith("/v1/admin/auth/"): + await self.app(scope, receive, send) + return + headers = [(k, v) for k, v in scope.get("headers") or []] + raw_auth = next((v.decode("latin-1") for k, v in headers if k.lower() == b"authorization"), "") + presented = _bearer(raw_auth) + method = str(scope.get("method") or "GET").upper() + + identity: accounts.AdminIdentity | None = None + if presented.startswith(accounts.SESSION_PREFIX): + identity = await anyio.to_thread.run_sync(accounts.validate_session, self.db, presented) + if identity is None: + await self._reject(send, 401, "your admin session has ended; sign in again") + return + elif presented and env_token_matches(presented, self.settings.admin_token): + if not accounts.bootstrap_token_api_enabled(): + await self._reject(send, 401, "the bootstrap admin token is disabled; sign in with an administrator account") + return + identity = accounts.BOOTSTRAP_IDENTITY + else: + await self.app(scope, receive, send) # existing checks answer 401 + return + + if method not in _READ_METHODS and not identity.can_write(): + await self._reject(send, 403, "your role is read-only") + return + if path.startswith("/v1/admin/accounts") and not identity.is_owner(): + await self._reject(send, 403, "only owners can manage administrators") + return + + if identity is not accounts.BOOTSTRAP_IDENTITY: + # Present the request to the existing admin checks. + internal = f"Bearer {self.settings.admin_token}".encode("latin-1") + headers = [(k, v) for k, v in headers if k.lower() != b"authorization"] + [(b"authorization", internal)] + scope = {**scope, "headers": headers} + token = accounts.CURRENT_ADMIN.set(identity) + try: + await self.app(scope, receive, send) + finally: + accounts.CURRENT_ADMIN.reset(token) + + +# --- request models ------------------------------------------------------------------------- + +class BootstrapRequest(BaseModel): + bootstrap_token: str + email: str + display_name: str = "" + + +class SetupRequest(BaseModel): + setup_token: str + password: str = "" + totp_code: str = "" + + +class LoginRequest(BaseModel): + email: str + password: str + totp_code: str + + +class AdminCreateRequest(BaseModel): + email: str + display_name: str = "" + role: str = "admin" + + +class AdminUpdateRequest(BaseModel): + role: str | None = None + disabled: bool | None = None + + +class EmployeeRequest(BaseModel): + email: str + display_name: str = "" + teams: list[str] | None = None + + +class ImportRequest(BaseModel): + csv: str + + +class InviteRequest(BaseModel): + organization_name: str = "" + gateway_url: str = "" + expires_days: int = 7 + max_devices: int = 2 + require_sso: bool | None = None + + +class LinkRequest(BaseModel): + employee_id: str + reattribute_history: bool = False + + +class IdentitySettingsRequest(BaseModel): + require_verified_identity: bool + + +class CodeRequest(BaseModel): + code: str + + +class SSOStartRequest(BaseModel): + purpose: str + organization_id: str = "" + invite_token: str = "" + + +# --- installer ------------------------------------------------------------------------------------ + +def _page(name: str) -> Response: + nonce = secrets.token_urlsafe(18) + html = (_HERE / name).read_text(encoding="utf-8").replace("__NONCE__", nonce) + csp = ( + "default-src 'none'; " + f"script-src 'nonce-{nonce}'; " + "style-src 'unsafe-inline'; " + "connect-src 'self'; img-src data:; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + return Response(content=html, media_type="text/html; charset=utf-8", headers={ + "Content-Security-Policy": csp, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", + }) + + +def _take(app: FastAPI, path: str, method: str) -> Callable[..., Any] | None: + for route in list(app.router.routes): + if getattr(route, "path", None) == path and method in (getattr(route, "methods", None) or set()): + app.router.routes.remove(route) + return getattr(route, "endpoint", None) + return None + + +def install_identity( + app: FastAPI, + *, + db: GatewayDB, + settings: GatewaySettings, + sso_settings: SSOSettings | None = None, + http_factory: Callable[[], Any] | None = None, +) -> None: + if getattr(app.state, "owg_identity_installed", False): + return + sso_settings = sso_settings or SSOSettings.from_env() + sso = SSOClient(sso_settings, db, **({"http_factory": http_factory} if http_factory else {})) + app.state.sso_client = sso + + def init_schemas() -> None: + accounts.init_admin_schema(db) + roster.init_employee_schema(db) + me_access.init_me_schema(db) + init_sso_schema(db) + + # Like the other Gateway modules: tables are created at server startup, never at import. + extend_lifespan(app, startup=init_schemas) + + def require_admin(authorization: str | None) -> accounts.AdminIdentity: + if not env_token_matches(_bearer(authorization), settings.admin_token): + raise HTTPException(status_code=401, detail="Gateway administrator sign-in required") + return accounts.current_admin() or accounts.BOOTSTRAP_IDENTITY + + def base_url(request: Request) -> str: + return (sso_settings.public_url or os.getenv("OWG_GATEWAY_PUBLIC_URL", "") or str(request.base_url)).rstrip("/") + + def audit(org: str, action: str, details: dict[str, Any]) -> None: + db.audit(organization_id=org or "*", principal_id="gateway-admin", action=action, details=details) + + # ---------------- admin sign-in ------------------------------------------------------------- + + @app.get("/v1/admin/auth/state") + def auth_state() -> dict[str, Any]: + return { + "needs_bootstrap": accounts.admin_count(db) == 0, + "sso_enabled": sso_settings.enabled, + "bootstrap_token_api_enabled": accounts.bootstrap_token_api_enabled(), + } + + @app.post("/v1/admin/auth/bootstrap") + def auth_bootstrap(body: BootstrapRequest, request: Request) -> dict[str, Any]: + if not env_token_matches(body.bootstrap_token.strip(), settings.admin_token): + raise HTTPException(status_code=401, detail="the bootstrap token is not correct") + try: + created = accounts.bootstrap_first_owner(db, email=body.email, display_name=body.display_name) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + audit("*", "admin.account.bootstrapped", {"email": created["email"], "role": "owner"}) + return {**created, "setup_url": f"{base_url(request)}/admin#setup={created['setup_token']}"} + + @app.post("/v1/admin/auth/setup/start") + def setup_start(body: SetupRequest) -> dict[str, Any]: + try: + return accounts.start_setup(db, body.setup_token) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + + @app.post("/v1/admin/auth/setup/complete") + def setup_complete(body: SetupRequest) -> dict[str, Any]: + try: + admin, session = accounts.complete_setup(db, body.setup_token, password=body.password, totp_code=body.totp_code) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + db.audit(organization_id="*", principal_id=f"admin:{admin['email']}", action="admin.account.activated", details={"email": admin["email"]}) + return {"session_token": session, "admin": admin} + + @app.post("/v1/admin/auth/login") + def auth_login(body: LoginRequest, request: Request) -> dict[str, Any]: + source = request.client.host if request.client else "" + try: + identity, session = accounts.login(db, email=body.email, password=body.password, totp_code=body.totp_code, source=source) + except accounts.AdminAuthError as exc: + db.audit(organization_id="*", principal_id="anonymous", action="admin.login.failed", details={"email": str(body.email)[:120].lower()}) + raise _error(exc) from exc + db.audit(organization_id="*", principal_id=identity.audit_id, action="admin.login", details={"method": "password_totp"}) + return {"session_token": session, "admin": dataclasses.asdict(identity)} + + @app.post("/v1/admin/auth/logout") + def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: + token = _bearer(authorization) + if token.startswith(accounts.SESSION_PREFIX): + accounts.revoke_session(db, token) + return {"signed_out": True} + + @app.get("/v1/admin/auth/me") + def auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]: + token = _bearer(authorization) + if token.startswith(accounts.SESSION_PREFIX): + identity = accounts.validate_session(db, token) + elif token and env_token_matches(token, settings.admin_token) and accounts.bootstrap_token_api_enabled(): + identity = accounts.BOOTSTRAP_IDENTITY + else: + identity = None + if identity is None: + raise HTTPException(status_code=401, detail="not signed in") + return {**dataclasses.asdict(identity), "can_write": identity.can_write(), "is_owner": identity.is_owner()} + + # ---------------- administrator accounts (owners) --------------------------------------------- + + @app.get("/v1/admin/accounts") + def admins_list(authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + return {"items": accounts.list_admins(db), "roles": list(accounts.ROLES)} + + @app.post("/v1/admin/accounts") + def admins_create(body: AdminCreateRequest, request: Request, authorization: str | None = Header(default=None)) -> dict[str, Any]: + actor = require_admin(authorization) + try: + created = accounts.create_admin(db, email=body.email, display_name=body.display_name, role=body.role, created_by=actor.audit_id) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + audit("*", "admin.account.created", {"email": created["email"], "role": created["role"]}) + return {**created, "setup_url": f"{base_url(request)}/admin#setup={created['setup_token']}"} + + @app.patch("/v1/admin/accounts/{admin_id}") + def admins_update(admin_id: str, body: AdminUpdateRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + actor = require_admin(authorization) + if admin_id == actor.admin_id and (body.disabled or (body.role and body.role != "owner")): + raise HTTPException(status_code=409, detail="you cannot disable or demote yourself; ask another owner") + try: + updated = accounts.update_admin(db, admin_id, role=body.role, disabled=body.disabled) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + audit("*", "admin.account.updated", {"email": updated["email"], "role": updated["role"], "status": updated["status"]}) + return updated + + @app.post("/v1/admin/accounts/{admin_id}/reset") + def admins_reset(admin_id: str, request: Request, authorization: str | None = Header(default=None)) -> dict[str, Any]: + actor = require_admin(authorization) + if admin_id == actor.admin_id: + raise HTTPException(status_code=409, detail="ask another owner to reset your sign-in") + try: + reset = accounts.reset_admin(db, admin_id) + except accounts.AdminAuthError as exc: + raise _error(exc) from exc + audit("*", "admin.account.reset", {"email": reset["email"]}) + return {**reset, "setup_url": f"{base_url(request)}/admin#setup={reset['setup_token']}"} + + # ---------------- roster -------------------------------------------------------------------------- + + @app.get("/v1/admin/people/{organization_id}") + def people(organization_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + return {**roster.people_and_devices(db, organization_id), "sso_enabled": sso_settings.enabled} + except roster.EmployeeError as exc: + raise _error(exc) from exc + + @app.post("/v1/admin/employees/{organization_id}") + def employee_upsert(organization_id: str, body: EmployeeRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + employee, created = roster.upsert_employee(db, organization_id, email=body.email, display_name=body.display_name, teams=body.teams) + except roster.EmployeeError as exc: + raise _error(exc) from exc + audit(organization_id, "employee.created" if created else "employee.updated", {"employee_actor_id": employee["actor_id"], "teams": body.teams}) + return {**employee, "created": created} + + @app.post("/v1/admin/employees/{organization_id}/import") + def employee_import(organization_id: str, body: ImportRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + result = roster.import_csv(db, organization_id, body.csv) + except roster.EmployeeError as exc: + raise _error(exc) from exc + # One row per person, so each employee's /me log shows how they got on the roster. + for change in result.pop("changed"): + audit(organization_id, "employee.created" if change["created"] else "employee.updated", + {"employee_actor_id": change["actor_id"], "via": "csv_import"}) + audit(organization_id, "employee.imported", {k: result[k] for k in ("created", "updated", "error_count")}) + return result + + @app.post("/v1/admin/employees/{organization_id}/{employee_id}/offboard") + def employee_offboard(organization_id: str, employee_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + result = roster.offboard_employee(db, organization_id, employee_id) + except roster.EmployeeError as exc: + raise _error(exc) from exc + audit(organization_id, "employee.offboarded", {"employee_actor_id": result["actor_id"], "revoked_devices": result["revoked_devices"]}) + return result + + @app.post("/v1/admin/employees/{organization_id}/{employee_id}/invites") + def employee_invite(organization_id: str, employee_id: str, body: InviteRequest, request: Request, authorization: str | None = Header(default=None)) -> dict[str, Any]: + actor = require_admin(authorization) + require_sso = sso_settings.enabled if body.require_sso is None else bool(body.require_sso) + if require_sso and not sso_settings.enabled: + raise HTTPException(status_code=400, detail="company sign-in (SSO) is not configured on this Gateway") + try: + invite = roster.create_personal_invite( + db, organization_id, employee_id, + organization_name=body.organization_name, expires_days=body.expires_days, + max_devices=body.max_devices, require_sso=require_sso, created_by=actor.audit_id, + ) + except roster.EmployeeError as exc: + raise _error(exc) from exc + audit(organization_id, "employee.invite.created", { + "employee_actor_id": invite["employee"]["actor_id"], "invite_id": invite["invite_id"], + "require_sso": invite["require_sso"], "expires_at": invite["expires_at"], + }) + from shared.join_code import encode_join_code + # The address employees use: what the admin's browser uses, else the configured + # public URL. Behind a TLS-terminating proxy the request itself looks like http. + gateway = (body.gateway_url.strip() or sso_settings.public_url or os.getenv("OWG_GATEWAY_PUBLIC_URL", "") or base_url(request)).rstrip("/") + try: + invite["join_code"] = encode_join_code( + gateway_url=gateway, organization_id=organization_id, token=invite["token"], + organization_name=body.organization_name or organization_id, + ) + except ValueError as exc: + roster.revoke_personal_invite(db, organization_id, invite["invite_id"]) + raise HTTPException( + status_code=400, + detail=f"{exc}. Open the admin console at the https address employees use, or set OWG_GATEWAY_PUBLIC_URL.", + ) from exc + invite["verify_url"] = f"{gateway}/join/verify#code={quote(invite['join_code'])}" if invite["require_sso"] else None + return invite + + @app.get("/v1/admin/personal-invites/{organization_id}") + def invites_list(organization_id: str, employee_id: str = "", authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + return {"items": roster.list_personal_invites(db, organization_id, employee_id=employee_id)} + except roster.EmployeeError as exc: + raise _error(exc) from exc + + @app.delete("/v1/admin/personal-invites/{organization_id}/{invite_id}") + def invites_revoke(organization_id: str, invite_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + if not roster.revoke_personal_invite(db, organization_id, invite_id): + raise HTTPException(status_code=404, detail="invitation not found or already revoked") + audit(organization_id, "employee.invite.revoked", {"invite_id": invite_id}) + return {"invite_id": invite_id, "revoked": True} + + @app.post("/v1/admin/devices/{organization_id}/{device_id}/link") + def device_link(organization_id: str, device_id: str, body: LinkRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + actor = require_admin(authorization) + try: + result = roster.link_device(db, organization_id, device_id, body.employee_id, + reattribute_history=body.reattribute_history, linked_by=actor.audit_id) + except roster.EmployeeError as exc: + raise _error(exc) from exc + audit(organization_id, "device.linked_to_employee", { + "device_id": device_id, "employee_actor_id": result["employee"]["actor_id"], + "previous_actor_ids": result["previous_actor_ids"], "reattributed_events": result["reattributed_events"], + }) + return result + + @app.get("/v1/admin/identity-settings/{organization_id}") + def identity_get(organization_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + return { + **roster.org_settings(db, organization_id), + "sso": {"enabled": sso_settings.enabled, "issuer": sso_settings.issuer or None, + "redirect_uri": sso_settings.redirect_uri if sso_settings.enabled else None, + "allowed_domains": list(sso_settings.allowed_domains)}, + "bootstrap_token_api_enabled": accounts.bootstrap_token_api_enabled(), + "public_url": sso_settings.public_url or os.getenv("OWG_GATEWAY_PUBLIC_URL", "") or None, + } + + @app.put("/v1/admin/identity-settings/{organization_id}") + def identity_put(organization_id: str, body: IdentitySettingsRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + require_admin(authorization) + try: + result = roster.set_org_settings(db, organization_id, require_verified_identity=body.require_verified_identity) + except roster.EmployeeError as exc: + raise _error(exc) from exc + audit(organization_id, "identity.settings.updated", {"require_verified_identity": body.require_verified_identity}) + return result + + # ---------------- enrollment with identity ----------------------------------------------------- + + previous_enroll = _take(app, "/v1/devices/enroll", "POST") + previous_preview = _take(app, "/v1/devices/join-preview", "GET") + if previous_enroll is None or previous_preview is None: + raise RuntimeError("install the organization admin console before identity") + + @app.get("/v1/devices/join-preview") + def join_preview(request: Request, authorization: str | None = Header(default=None)) -> dict[str, Any]: + token = _bearer(authorization) + if token.startswith(roster.PERSON_TOKEN_PREFIX + "_"): + invite = roster.peek_personal_invite(db, token) + if invite is None: + raise HTTPException(status_code=401, detail="this invitation is invalid, expired, revoked or already used; ask your IT admin for a new one") + policy = db.get_policy(invite["organization_id"]) + return { + "organization_id": invite["organization_id"], + "organization_name": invite["organization_name"], + "expires_at": invite["expires_at"], + "seats_left": invite["devices_left"], + "identity": { + "locked": True, "email": invite["email"], "display_name": invite["display_name"], + "actor_id": invite["actor_id"], "require_sso": invite["require_sso"], + "sso_verified": invite["sso_verified"], + "verify_path": "/join/verify" if invite["require_sso"] else None, + }, + "sharing": _sharing(policy), + "never_shared": _NEVER_SHARED, + "you_can": _YOU_CAN, + } + preview = previous_preview(authorization) + verified_only = roster.org_settings(db, preview["organization_id"])["require_verified_identity"] + if verified_only: + raise HTTPException(status_code=403, detail="your organization requires a personal invitation; ask your IT admin for one") + preview["identity"] = {"locked": False, "require_sso": False} + return preview + + def _enrollment_organization(token: str) -> str: + """The organization an enrollment code is bound to, without consuming it.""" + if token.startswith("owg_enroll_link_"): + link = peek_enrollment_link(db, token) + return str(link["organization_id"]) if link else "" + with db.connect() as conn: + cur = db._execute(conn, "SELECT organization_id FROM enrollment_grants WHERE token_hash = ?", (token_hash(token),)) + row = db._row(cur.fetchone(), [d[0] for d in cur.description] if cur.description else None) + return str(row["organization_id"]) if row else "" # the legacy shared token is not bound + + @app.post("/v1/devices/enroll") + def enroll(request: EnrollmentRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]: + token = _bearer(authorization) + if token.startswith(roster.PERSON_TOKEN_PREFIX + "_"): + try: + result = roster.enroll_with_personal_invite( + db, token=token, requested_organization_id=request.organization_id, device_id=request.device_id, + ) + except roster.EmployeeError as exc: + raise _error(exc) from exc + db.audit( + organization_id=result["organization_id"], principal_id=result["token_id"], action="device.enrolled", + details={"device_id": result["device_id"], "invite_id": result["invite_id"], "identity_source": result["identity_source"], + "employee_actor_id": result["actor_id"], "personal_invite": True}, + ) + return {k: v for k, v in result.items() if k != "invite_id"} + # Decide by the organization the code is bound to: a client could leave + # organization_id empty, and bound codes then take it from the code. + org = _enrollment_organization(token) or str(request.organization_id or "").strip() + if org and roster.org_settings(db, org)["require_verified_identity"]: + raise HTTPException(status_code=403, detail="your organization requires a personal invitation; ask your IT admin for one") + result = previous_enroll(request, authorization) + try: + roster.record_self_reported(db, result["organization_id"], result["device_id"], linked_by="self_reported_at_enrollment") + except Exception: + pass + result["identity_source"] = "self_reported" + return result + + # ---------------- employee self-service (/me) ------------------------------------------------------- + + @app.post("/v1/devices/me-link") + def me_link(request: Request, authorization: str | None = Header(default=None)) -> dict[str, Any]: + principal = db.authenticate(_bearer(authorization)) + if principal is None or principal.token_type != "device": + raise HTTPException(status_code=401, detail="a connected OpenWorkGraph computer is required") + try: + code = me_access.create_login_code(db, organization_id=principal.organization_id, actor_id=principal.actor_id, device_id=principal.device_id) + except me_access.MeError as exc: + raise _error(exc) from exc + # The computer builds the link from the Gateway address it enrolled with. + return {"code": code, "path": f"/me#code={code}", "expires_in_seconds": me_access.CODE_SECONDS} + + @app.post("/v1/me/session") + def me_session(body: CodeRequest) -> dict[str, Any]: + try: + session, who = me_access.exchange_login_code(db, body.code) + except me_access.MeError as exc: + raise _error(exc) from exc + return {"session_token": session, **who} + + def me(authorization: str | None) -> dict[str, Any]: + who = me_access.validate_session(db, _bearer(authorization)) + if who is None: + raise HTTPException(status_code=401, detail="your session has ended; open the page again from OpenWorkGraph") + return who + + @app.get("/v1/me/overview") + def me_overview(authorization: str | None = Header(default=None)) -> dict[str, Any]: + who = me(authorization) + return {**me_access.overview(db, organization_id=who["organization_id"], actor_id=who["actor_id"]), "signed_in_with": who["auth_method"]} + + @app.get("/v1/me/evidence") + def me_evidence( + cursor: str | None = None, + limit: int = Query(default=50, ge=1, le=200), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from .query import workflow_trace + + who = me(authorization) + try: + payload = workflow_trace(db, organization_id=who["organization_id"], actor_id=who["actor_id"], cursor=cursor, limit=limit) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + db.audit( + organization_id=who["organization_id"], principal_id=f"employee:{who['actor_id']}", action="human_access.trace.read", + details={"actor_id": who["actor_id"], "access_mode": "self", "returned": payload.get("returned", 0), "via": "me_page"}, + ) + return payload + + @app.get("/v1/me/access-log") + def me_access_log(authorization: str | None = Header(default=None)) -> dict[str, Any]: + who = me(authorization) + return {"items": me_access.access_log(db, organization_id=who["organization_id"], actor_id=who["actor_id"])} + + @app.post("/v1/me/logout") + def me_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: + token = _bearer(authorization) + if token.startswith(me_access.SESSION_PREFIX): + me_access.revoke_session(db, token) + return {"signed_out": True} + + # ---------------- company sign-in -------------------------------------------------------------------- + + @app.post("/v1/sso/start") + def sso_start(body: SSOStartRequest) -> dict[str, Any]: + context: dict[str, Any] = {} + if body.purpose == "join": + invite = roster.peek_personal_invite(db, body.invite_token.strip()) + if invite is None: + raise HTTPException(status_code=401, detail="this invitation is invalid, expired, revoked or already used") + context = {"invite_id": invite["invite_id"]} + elif body.purpose == "me" and body.organization_id: + context = {"organization_id": body.organization_id.strip()[:512]} + try: + return {"redirect_url": sso.start(body.purpose, context)} + except SSOError as exc: + raise _error(exc) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail="the company sign-in provider could not be reached") from exc + + @app.get("/sso/callback", include_in_schema=False) + def sso_callback(code: str = "", state: str = "", error: str = "", error_description: str = "") -> Response: + if error: + return RedirectResponse("/?error=" + quote((error_description or error)[:200]), status_code=303) + try: + purpose, context, identity = sso.finish(code=code, state=state) + except SSOError as exc: + return RedirectResponse("/?error=" + quote(str(exc)), status_code=303) + except Exception: + return RedirectResponse("/?error=" + quote("company sign-in failed; try again"), status_code=303) + try: + if purpose == "admin": + admin, session = accounts.login_sso(db, email=identity["email"]) + db.audit(organization_id="*", principal_id=admin.audit_id, action="admin.login", details={"method": "sso"}) + return RedirectResponse(f"/admin#session={session}", status_code=303) + if purpose == "me": + session, _who = me_access.sso_session(db, email=identity["email"], organization_id=str(context.get("organization_id") or "")) + return RedirectResponse(f"/me#session={session}", status_code=303) + if purpose == "join": + invite = roster.mark_invite_verified_by_id(db, str(context.get("invite_id") or ""), verified_email=identity["email"], subject=identity["subject"]) + db.audit(organization_id=invite["organization_id"], principal_id=f"employee:{invite['actor_id']}", action="employee.invite.sso_verified", + details={"invite_id": invite["invite_id"], "employee_actor_id": invite["actor_id"]}) + return RedirectResponse("/join/verify#verified=" + quote(invite["email"]), status_code=303) + except (accounts.AdminAuthError, me_access.MeError, roster.EmployeeError) as exc: + target = {"admin": "/admin", "me": "/me", "join": "/join/verify"}.get(purpose, "/") + return RedirectResponse(f"{target}#error=" + quote(str(exc)), status_code=303) + return RedirectResponse("/", status_code=303) + + # ---------------- pages --------------------------------------------------------------------------------- + + @app.get("/", include_in_schema=False) + def landing() -> Response: + return _page("landing.html") + + @app.get("/me", include_in_schema=False) + def me_page() -> Response: + return _page("me_page.html") + + @app.get("/join/verify", include_in_schema=False) + def join_verify_page() -> Response: + return _page("join_verify.html") + + app.add_middleware(AdminIdentityMiddleware, db=db, settings=settings) + app.state.owg_identity_installed = True + + +_NEVER_SHARED = [ + "typed text", "clipboard contents", "screenshots", "passwords", + "evidence recorded before you join", "anything recorded while you pause sharing", +] +_YOU_CAN = ["pause sharing at any time", "disconnect at any time", "see exactly what was shared in your dashboard"] + + +def _sharing(policy: dict[str, Any]) -> dict[str, Any]: + return { + "shares_window_titles": bool(policy.get("share_window_titles", True)), + "shares_metadata": bool(policy.get("share_metadata", True)), + "shares_excluded_apps": bool(policy.get("share_excluded", False)), + "shares_agent_activity": bool(policy.get("allow_agent_events", False)), + "limited_to_event_types": list(policy.get("allowed_event_types") or []), + "forces_redacted_ai_context": bool(policy.get("force_redacted_ai_context", False)), + } + + +class _RosterMappingVerifier: + """Map an SSO identity (sub or email) to the roster employee's actor id.""" + + def __init__(self, inner: Any, settings: Any, db: GatewayDB) -> None: + self.inner = inner + self.settings = settings + self.db = db + + def verify(self, token: str) -> dict[str, Any]: + claims = dict(self.inner.verify(token)) + org = _claim(claims, self.settings.organization_claim) + actor = str(_claim(claims, self.settings.actor_claim) or "").strip() + if not org or not actor: + return claims + with self.db.connect() as conn: + cur = self.db._execute( + conn, + "SELECT actor_id FROM gateway_employees WHERE organization_id = ? AND status = 'active' AND (sso_subject = ? OR email = ?)", + (str(org), actor, actor.lower()), + ) + row = cur.fetchone() + if row: + mapped = row[0] if not hasattr(row, "keys") else row["actor_id"] + _set_claim(claims, self.settings.actor_claim, str(mapped)) + return claims + + +def _claim(payload: dict[str, Any], path: str) -> Any: + value: Any = payload + for part in [x for x in str(path).split(".") if x]: + if not isinstance(value, dict): + return None + value = value.get(part) + return value + + +def _set_claim(payload: dict[str, Any], path: str, value: Any) -> None: + parts = [x for x in str(path).split(".") if x] + target = payload + for part in parts[:-1]: + nxt = target.get(part) + if not isinstance(nxt, dict): + return + target = nxt + if parts: + target[parts[-1]] = value + + +def install_roster_mapping(app: FastAPI, db: GatewayDB) -> None: + """Map SSO identities in the human API to roster employees (call after human access).""" + verifier = getattr(app.state, "human_oidc_verifier", None) + human_settings = getattr(app.state, "human_access_settings", None) + if verifier is not None and human_settings is not None and not isinstance(verifier, _RosterMappingVerifier): + app.state.human_oidc_verifier = _RosterMappingVerifier(verifier, human_settings, db) + + +__all__ = ["install_identity", "install_roster_mapping", "AdminIdentityMiddleware"] diff --git a/gateway/join_verify.html b/gateway/join_verify.html new file mode 100644 index 00000000..1943f10f --- /dev/null +++ b/gateway/join_verify.html @@ -0,0 +1,46 @@ + + + + + + +OpenWorkGraph · Confirm your invitation + + + +

Confirm your OpenWorkGraph invitation

Loading…

+ + + diff --git a/gateway/landing.html b/gateway/landing.html new file mode 100644 index 00000000..85d07320 --- /dev/null +++ b/gateway/landing.html @@ -0,0 +1,31 @@ + + + + + + +OpenWorkGraph Gateway + + + +
+

OpenWorkGraph Gateway

+

Your organization's self-hosted Gateway. It receives only what enrolled computers are allowed to share.

+ + +

Your own recorded work stays in OpenWorkGraph on your computer. To join your organization, use the personal invitation your IT admin sent you, in OpenWorkGraph → Organization → Join your organization.

+
+ + + diff --git a/gateway/me_access.py b/gateway/me_access.py new file mode 100644 index 00000000..da069d0a --- /dev/null +++ b/gateway/me_access.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +"""Employee self-service: "what does my organization hold about me?". + +An employee reaches /me in one of two ways: + +* from their own OpenWorkGraph dashboard: the enrolled computer asks the + Gateway for a one-time login code with its device credential (valid for two + minutes, single use), then opens ``/me#code=...`` in the browser; +* with company SSO, when configured: the verified email must belong to an + active roster employee. + +A session only ever reads data for that employee's own ``actor_id``. +""" + +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +from shared.time_utils import normalize_timestamp +from .auth import token_hash +from .db import GatewayDB + +CODE_SECONDS = 120 +SESSION_HOURS = 8 +SESSION_IDLE_MINUTES = 30 +CODE_PREFIX = "owg_me_code_" +SESSION_PREFIX = "owg_me_session_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS gateway_me_codes ( + code_hash TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); +CREATE TABLE IF NOT EXISTS gateway_me_sessions ( + session_hash TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + auth_method TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +) +""" + + +class MeError(ValueError): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _ts(value: datetime) -> str: + return normalize_timestamp(value.isoformat()) + + +def _one(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any]: + cur = db._execute(conn, sql, params) + row = cur.fetchone() + columns = [d[0] for d in cur.description] if cur.description else None + return db._row(row, columns) + + +def _all(db: GatewayDB, conn: Any, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + cur = db._execute(conn, sql, params) + rows = cur.fetchall() + columns = [d[0] for d in cur.description] if cur.description else None + return [db._row(r, columns) for r in rows] + + +def init_me_schema(db: GatewayDB) -> None: + with db.connect() as conn: + for statement in [x.strip() for x in SCHEMA.split(";") if x.strip()]: + conn.execute(statement) + + +def create_login_code(db: GatewayDB, *, organization_id: str, actor_id: str, device_id: str) -> str: + if not actor_id: + raise MeError("this computer has no work identity on the Gateway; ask your IT admin to link it", status_code=409) + code = CODE_PREFIX + secrets.token_urlsafe(32) + now = _now() + with db.connect() as conn: + db._execute( + conn, + "INSERT INTO gateway_me_codes(code_hash, organization_id, actor_id, device_id, created_at, expires_at, used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)", + (token_hash(code), organization_id, actor_id, device_id, _ts(now), _ts(now + timedelta(seconds=CODE_SECONDS))), + ) + return code + + +def _create_session(db: GatewayDB, *, organization_id: str, actor_id: str, auth_method: str) -> str: + token = SESSION_PREFIX + secrets.token_urlsafe(32) + now = _now() + with db.connect() as conn: + db._execute( + conn, + "INSERT INTO gateway_me_sessions(session_hash, organization_id, actor_id, auth_method, created_at, last_seen_at, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)", + (token_hash(token), organization_id, actor_id, auth_method, _ts(now), _ts(now), _ts(now + timedelta(hours=SESSION_HOURS))), + ) + return token + + +def exchange_login_code(db: GatewayDB, code: str) -> tuple[str, dict[str, Any]]: + value = str(code or "").strip() + now = _now() + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_me_codes WHERE code_hash = ?", (token_hash(value),)) if value.startswith(CODE_PREFIX) else {} + if not row or row.get("used_at") or row["expires_at"] < _ts(now): + raise MeError("this link has expired or was already used; open it again from OpenWorkGraph", status_code=401) + db._execute(conn, "UPDATE gateway_me_codes SET used_at = ? WHERE code_hash = ?", (_ts(now), row["code_hash"])) + # The device must still be enrolled at the moment the code is used. + active = _one( + db, conn, + "SELECT token_id FROM access_tokens WHERE organization_id = ? AND device_id = ? AND token_type = 'device' AND revoked_at IS NULL AND actor_id = ?", + (row["organization_id"], row["device_id"], row["actor_id"]), + ) + if not active: + raise MeError("this computer is no longer connected to the organization", status_code=401) + session = _create_session(db, organization_id=row["organization_id"], actor_id=row["actor_id"], auth_method="device_link") + return session, {"organization_id": row["organization_id"], "actor_id": row["actor_id"]} + + +def sso_session(db: GatewayDB, *, email: str, organization_id: str = "") -> tuple[str, dict[str, Any]]: + from .employees import employee_by_email + + matches = employee_by_email(db, organization_id or None, email) + if not matches: + raise MeError(f"{email} is not in your organization's OpenWorkGraph roster; ask your IT admin", status_code=403) + employee = matches[0] + session = _create_session(db, organization_id=employee["organization_id"], actor_id=employee["actor_id"], auth_method="sso") + return session, {"organization_id": employee["organization_id"], "actor_id": employee["actor_id"]} + + +def validate_session(db: GatewayDB, token: str) -> dict[str, Any] | None: + if not str(token or "").startswith(SESSION_PREFIX): + return None + now = _now() + with db.connect() as conn: + row = _one(db, conn, "SELECT * FROM gateway_me_sessions WHERE session_hash = ?", (token_hash(token),)) + if not row or row.get("revoked_at") or row["expires_at"] < _ts(now) or row["last_seen_at"] < _ts(now - timedelta(minutes=SESSION_IDLE_MINUTES)): + return None + db._execute(conn, "UPDATE gateway_me_sessions SET last_seen_at = ? WHERE session_hash = ?", (_ts(now), row["session_hash"])) + return {"organization_id": row["organization_id"], "actor_id": row["actor_id"], "auth_method": row["auth_method"]} + + +def revoke_session(db: GatewayDB, token: str) -> None: + with db.connect() as conn: + db._execute(conn, "UPDATE gateway_me_sessions SET revoked_at = ? WHERE session_hash = ?", (_ts(_now()), token_hash(token))) + + +# --- views --------------------------------------------------------------------------------------- + +_READ_MODES = { + "self": "You read your own evidence", + "team": "A team lead with access to your team read your evidence", + "organization": "Someone with organization-wide access read your evidence", +} +_READ_ACTIONS = {"human_access.trace.read", "human_access.search.read"} +# Reads by integration tokens; with an empty actor_id they covered every employee. +_INTEGRATION_READS = { + "evidence.trace.read": "An integration read your evidence", + "evidence.search": "An integration searched your evidence", + "context.current.read": "An integration read your current context", + "transfers.read": "An integration read handoffs involving you", +} +_ORG_WIDE_READS = { + "evidence.trace.read": "An integration read everyone's evidence, including yours", + "evidence.search": "An integration searched everyone's evidence, including yours", + "context.current.read": "An integration read everyone's current context, including yours", + "transfers.read": "An integration read handoffs across the organization", +} +_ORG_WIDE = '%"actor_id": ""%' # json.dumps default separators, as db.audit writes it +_EVENTS = { + "employee.created": "You were added to the employee roster", + "employee.updated": "Your roster entry was changed", + "employee.offboarded": "You were offboarded; your computers were disconnected", + "employee.invite.created": "A personal invitation was created for you", + "employee.invite.revoked": "A personal invitation for you was revoked", + "employee.invite.sso_verified": "You confirmed a personal invitation with your company account", + "device.enrolled": "A computer joined as you", + "device.linked_to_employee": "A computer was linked to you by an administrator", + "device.admin_revoked": "One of your computers was disconnected by an administrator", + "device.revoked": "One of your computers was disconnected", + "human_access.actor_teams.updated": "Your teams were changed", +} + + +def overview(db: GatewayDB, *, organization_id: str, actor_id: str) -> dict[str, Any]: + from .employees import employee_by_actor, people_and_devices + from .human_access import actor_teams + from .lifecycle import get_retention_policy + + employee = employee_by_actor(db, organization_id, actor_id) + policy = db.get_policy(organization_id) + everyone = people_and_devices(db, organization_id) + devices: list[dict[str, Any]] = [] + for person in everyone["employees"]: + if person["actor_id"] == actor_id: + devices = person["devices"] + if not devices: + devices = [d for d in everyone["unlinked_devices"] if d["actor_id"] == actor_id] + now = _now() + with db.connect() as conn: + counts = {} + for days in (7, 30): + row = _one( + db, conn, + "SELECT COUNT(*) AS n FROM evidence_events WHERE organization_id = ? AND actor_id = ? AND observed_at >= ?", + (organization_id, actor_id, _ts(now - timedelta(days=days))), + ) + counts[f"last_{days}_days"] = int(row.get("n") or 0) + total = _one(db, conn, "SELECT COUNT(*) AS n, MIN(observed_at) AS first_at FROM evidence_events WHERE organization_id = ? AND actor_id = ?", (organization_id, actor_id)) + readers = _all( + db, conn, + "SELECT token_id, scopes_json, created_at FROM access_tokens WHERE organization_id = ? AND token_type = 'integration' AND revoked_at IS NULL", + (organization_id,), + ) + org_readers = 0 + for token in readers: + try: + scopes = set(json.loads(token.get("scopes_json") or "[]")) + except Exception: + scopes = set() + if scopes & {"evidence:read", "context:read", "transfers:read"}: + org_readers += 1 + retention = get_retention_policy(db, organization_id) + return { + "organization_id": organization_id, + "you": { + "actor_id": actor_id, + "email": employee["email"] if employee else actor_id, + "display_name": employee["display_name"] if employee else "", + "in_roster": bool(employee), + "teams": sorted(actor_teams(db, organization_id, actor_id)), + }, + "computers": devices, + "evidence": {**counts, "total": int(total.get("n") or 0), "first_at": total.get("first_at")}, + "what_is_shared": { + "window_and_page_titles": bool(policy.get("share_window_titles", True)), + "structural_details": bool(policy.get("share_metadata", True)), + "excluded_apps": bool(policy.get("share_excluded", False)), + "ai_agent_activity_permitted": bool(policy.get("allow_agent_events", False)), + "limited_to_event_types": list(policy.get("allowed_event_types") or []), + }, + "never_shared": ["typed text", "clipboard contents", "screenshots", "passwords"], + "who_can_read": { + "you": True, + "team_leads_of_your_teams": sorted(actor_teams(db, organization_id, actor_id)), + "integrations_with_organization_read_access": org_readers, + "gateway_administrators_can_read_evidence": False, + "note": "Administrators manage computers, invitations and policy; reading evidence needs an explicit access scope, and every read is logged.", + }, + "retention_days": retention.get("retention_days"), + } + + +def _who(principal: str, details: dict[str, Any], actor_id: str) -> str: + """A readable name for an audit principal.""" + if principal == f"employee:{actor_id}": + return "you" + if principal.startswith("admin:"): + return "administrator " + principal.split(":", 1)[1] + if principal == "bootstrap-token": + return "Gateway bootstrap token" + if principal == "gateway-admin": + return "Gateway administrator" + if principal.startswith("device_") and details.get("device_id"): + return f"computer {details['device_id']}" + return principal + + +def access_log(db: GatewayDB, *, organization_id: str, actor_id: str, limit: int = 100) -> list[dict[str, Any]]: + """Recorded reads of this employee's evidence and admin actions about them.""" + # The LIKE prefilter keeps this an indexed-org scan of only candidate rows; the + # exact match below decides. `_` and `%` in addresses only widen the prefilter. + needle = "%" + json.dumps(actor_id, ensure_ascii=False) + "%" # as db.audit writes it + with db.connect() as conn: + rows = _all( + db, conn, + "SELECT observed_at, principal_id, action, details_json FROM audit_log " + "WHERE organization_id = ? AND (details_json LIKE ? OR (action IN (?, ?, ?, ?) AND details_json LIKE ?)) " + "ORDER BY id DESC", + (organization_id, needle, *_INTEGRATION_READS, _ORG_WIDE), + ) + out = [] + for row in rows: + try: + details = json.loads(row.get("details_json") or "{}") + except Exception: + details = {} + action = row["action"] + about_me = details.get("actor_id") == actor_id or details.get("employee_actor_id") == actor_id + org_wide = action in _INTEGRATION_READS and not details.get("actor_id") + if not (about_me or org_wide): + continue + who = _who(str(row.get("principal_id") or ""), details, actor_id) + if action in _READ_ACTIONS: + mode = str(details.get("access_mode") or "") + label = _READ_MODES.get(mode, "Your evidence was read") + by = "you" if mode == "self" else who + elif org_wide: + label, by = _ORG_WIDE_READS[action], "integration " + who + elif action in _INTEGRATION_READS: + label, by = _INTEGRATION_READS[action], "integration " + who + else: + label = _EVENTS.get(action) or action.replace("_", " ").replace(".", " · ") + by = who + out.append({"at": row["observed_at"], "what": label, "by": by, "rows_returned": details.get("returned")}) + if len(out) >= limit: + break + return out diff --git a/gateway/me_page.html b/gateway/me_page.html new file mode 100644 index 00000000..d2fa1564 --- /dev/null +++ b/gateway/me_page.html @@ -0,0 +1,68 @@ + + + + + + +OpenWorkGraph · What your organization holds about you + + + +
YOU

What your organization holds about you

+
+

See what your organization holds about you

+

Open this page from OpenWorkGraph → Organization → See what your organization holds about you on your own computer. That link signs you in for this page only.

+ +

This page shows your own data only. Nobody else can open it with your link.

+
+
+
+

What your computers share with the organization

Set by your organization as a ceiling. Your own OpenWorkGraph can always share less, pause, or disconnect.

    +

    Who can read your evidence

      +

      Your computers

      ComputerHow it is linked to youJoinedLast evidenceEvents
      +

      Recorded reads and changes about you

      WhenWhatBy
      +

      Your evidence on the Gateway

      Exactly what the Gateway stored from your computers, oldest first. Viewing it here is also recorded as a read by you.

      TimeAppTitleType

      +
      +
      + + + diff --git a/gateway/sso.py b/gateway/sso.py new file mode 100644 index 00000000..43f5608b --- /dev/null +++ b/gateway/sso.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +"""Interactive company sign-in (OpenID Connect authorization code + PKCE). + +Used for three things, all optional: + +* administrators signing in to /admin (instead of password + authenticator); +* employees opening /me; +* employees confirming a personal invitation before their computer joins. + +Configuration (all via environment): + +* ``OWG_GATEWAY_SSO_ISSUER`` (defaults to ``OWG_GATEWAY_OIDC_ISSUER``) +* ``OWG_GATEWAY_SSO_CLIENT_ID`` and, for confidential clients, + ``OWG_GATEWAY_SSO_CLIENT_SECRET`` +* ``OWG_GATEWAY_PUBLIC_URL``: the https address people use to reach the + Gateway; the redirect URI is ``/sso/callback`` +* ``OWG_GATEWAY_SSO_ALLOWED_DOMAINS``: optional comma-separated email domains + +The ID token is verified against the provider's published keys (issuer, +audience, expiry, nonce). An explicit ``email_verified: false`` is rejected. +""" + +import base64 +import hashlib +import json +import os +import secrets +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any, Callable +from urllib.parse import urlencode, urlparse + +from shared.time_utils import normalize_timestamp +from .auth import token_hash +from .db import GatewayDB + +STATE_MINUTES = 10 +PURPOSES = ("admin", "me", "join") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS gateway_sso_states ( + state_hash TEXT PRIMARY KEY, + purpose TEXT NOT NULL, + nonce TEXT NOT NULL, + code_verifier TEXT NOT NULL, + context_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +) +""" + + +class SSOError(ValueError): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class SSOSettings: + issuer: str = "" + client_id: str = "" + client_secret: str = "" + public_url: str = "" + allowed_domains: tuple[str, ...] = () + + @property + def enabled(self) -> bool: + return bool(self.issuer and self.client_id and self.public_url) + + @property + def redirect_uri(self) -> str: + return self.public_url.rstrip("/") + "/sso/callback" + + @classmethod + def from_env(cls) -> "SSOSettings": + issuer = str(os.getenv("OWG_GATEWAY_SSO_ISSUER") or os.getenv("OWG_GATEWAY_OIDC_ISSUER") or "").strip().rstrip("/") + public_url = str(os.getenv("OWG_GATEWAY_PUBLIC_URL") or "").strip().rstrip("/") + domains = tuple( + d.strip().lower().lstrip("@") + for d in str(os.getenv("OWG_GATEWAY_SSO_ALLOWED_DOMAINS") or "").split(",") if d.strip() + ) + value = cls( + issuer=issuer, + client_id=str(os.getenv("OWG_GATEWAY_SSO_CLIENT_ID") or "").strip(), + client_secret=str(os.getenv("OWG_GATEWAY_SSO_CLIENT_SECRET") or "").strip(), + public_url=public_url, + allowed_domains=domains, + ) + if value.client_id and public_url: + parsed = urlparse(public_url) + local = (parsed.hostname or "") in {"localhost", "127.0.0.1", "::1"} + if parsed.scheme != "https" and not local: + raise ValueError("OWG_GATEWAY_PUBLIC_URL must use https for SSO") + return value + + +def init_sso_schema(db: GatewayDB) -> None: + with db.connect() as conn: + for statement in [x.strip() for x in SCHEMA.split(";") if x.strip()]: + conn.execute(statement) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _ts(value: datetime) -> str: + return normalize_timestamp(value.isoformat()) + + +@dataclass +class SSOClient: + settings: SSOSettings + db: GatewayDB + http_factory: Callable[[], Any] = field(default=lambda: __import__("httpx").Client(timeout=10)) + _discovery: dict[str, Any] | None = None + _discovery_at: float = 0.0 + _jwks_client: Any = None + _lock: threading.Lock = field(default_factory=threading.Lock) + + # -- provider metadata ---------------------------------------------------------------- + + def discovery(self) -> dict[str, Any]: + with self._lock: + if self._discovery and time.time() - self._discovery_at < 3600: + return self._discovery + url = self.settings.issuer + "/.well-known/openid-configuration" + with self.http_factory() as client: + response = client.get(url) + response.raise_for_status() + data = response.json() + if str(data.get("issuer") or "").rstrip("/") != self.settings.issuer: + raise SSOError("the SSO provider's issuer does not match OWG_GATEWAY_SSO_ISSUER", status_code=502) + for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"): + if not data.get(key): + raise SSOError(f"the SSO provider does not publish {key}", status_code=502) + with self._lock: + self._discovery, self._discovery_at, self._jwks_client = data, time.time(), None + return data + + def _signing_key(self, id_token: str) -> Any: + import jwt + + header = jwt.get_unverified_header(id_token) + with self.http_factory() as client: + response = client.get(self.discovery()["jwks_uri"]) + response.raise_for_status() + keys = response.json().get("keys") or [] + kid = header.get("kid") + for key in keys: + if not kid or key.get("kid") == kid: + return jwt.PyJWK(key).key + raise SSOError("the SSO provider's signing key was not found", status_code=502) + + # -- flow ------------------------------------------------------------------------------- + + def start(self, purpose: str, context: dict[str, Any] | None = None) -> str: + if not self.settings.enabled: + raise SSOError("company sign-in is not configured on this Gateway", status_code=404) + if purpose not in PURPOSES: + raise SSOError("unknown sign-in purpose") + meta = self.discovery() + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(24) + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).decode().rstrip("=") + now = _now() + with self.db.connect() as conn: + self.db._execute( + conn, + "INSERT INTO gateway_sso_states(state_hash, purpose, nonce, code_verifier, context_json, created_at, expires_at, used_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)", + (token_hash(state), purpose, nonce, verifier, json.dumps(context or {}), _ts(now), _ts(now + timedelta(minutes=STATE_MINUTES))), + ) + query = { + "response_type": "code", + "client_id": self.settings.client_id, + "redirect_uri": self.settings.redirect_uri, + "scope": "openid email profile", + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + return str(meta["authorization_endpoint"]) + ("&" if "?" in str(meta["authorization_endpoint"]) else "?") + urlencode(query) + + def finish(self, *, code: str, state: str) -> tuple[str, dict[str, Any], dict[str, Any]]: + """Return (purpose, context, identity) for a completed sign-in.""" + import jwt + + if not code or not state: + raise SSOError("the sign-in response is incomplete; start again") + now = _now() + with self.db.connect() as conn: + cur = self.db._execute(conn, "SELECT * FROM gateway_sso_states WHERE state_hash = ?", (token_hash(state),)) + row = cur.fetchone() + columns = [d[0] for d in cur.description] if cur.description else None + data = self.db._row(row, columns) + if not data or data.get("used_at") or data["expires_at"] < _ts(now): + raise SSOError("this sign-in attempt expired or was already used; start again", status_code=400) + self.db._execute(conn, "UPDATE gateway_sso_states SET used_at = ? WHERE state_hash = ?", (_ts(now), data["state_hash"])) + meta = self.discovery() + form = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.settings.redirect_uri, + "client_id": self.settings.client_id, + "code_verifier": data["code_verifier"], + } + if self.settings.client_secret: + form["client_secret"] = self.settings.client_secret + with self.http_factory() as client: + response = client.post(str(meta["token_endpoint"]), data=form, headers={"Accept": "application/json"}) + if response.status_code >= 400: + raise SSOError("the SSO provider rejected the sign-in; start again", status_code=502) + id_token = str((response.json() or {}).get("id_token") or "") + if not id_token: + raise SSOError("the SSO provider did not return an ID token", status_code=502) + try: + claims = jwt.decode( + id_token, + self._signing_key(id_token), + algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"], + audience=self.settings.client_id, + issuer=str(meta["issuer"]), + leeway=30, + options={"require": ["exp", "iat", "sub"]}, + ) + except SSOError: + raise + except Exception as exc: + raise SSOError("the sign-in token could not be verified", status_code=401) from exc + if claims.get("nonce") != data["nonce"]: + raise SSOError("the sign-in token does not belong to this attempt", status_code=401) + if claims.get("email_verified") is False: + raise SSOError("your company account's email is not verified", status_code=403) + email = str(claims.get("email") or claims.get("preferred_username") or claims.get("upn") or "").strip().lower() + if "@" not in email: + raise SSOError("your company account did not provide an email address", status_code=403) + domain = email.rsplit("@", 1)[1] + if self.settings.allowed_domains and domain not in self.settings.allowed_domains: + raise SSOError(f"accounts from {domain} cannot sign in to this Gateway", status_code=403) + identity = { + "subject": str(claims["sub"]), + "email": email, + "name": str(claims.get("name") or "")[:120], + } + return data["purpose"], json.loads(data.get("context_json") or "{}"), identity diff --git a/mcpb/manifest.json b/mcpb/manifest.json index fb515277..7da83945 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -2,9 +2,9 @@ "manifest_version": "0.3", "name": "openworkgraph-local", "display_name": "OpenWorkGraph", - "version": "0.95.0", + "version": "0.97.0", "description": "Connect Claude Desktop to the compact local OpenWorkGraph context surface.", - "long_description": "Uses the OpenWorkGraph installation already running on this computer. v0.95 adds a framework-neutral custom-harness setup flow plus standalone Python and Node helpers for privacy-safe structural agent telemetry; arbitrary MCP-capable harnesses can separately read authorized OpenWorkGraph context. v0.94 added explicit local history retention, separate saved-history AI access, lightweight history navigation, and structural browser-agent lifecycle observation. Canonical workflow evidence remains primary; Context Pulse provides incremental factual updates. Retained history is separately user-controlled: list_history can navigate saved human and agent sessions only while a time-limited saved-history lease is active. The legacy 24-tool MCP entrypoint remains available for existing configurations while new connections use this compact surface. Prompts, model responses, tool arguments/results, typed text, clipboard contents, exception text, returned values, and hidden reasoning are not captured by the custom agent helpers.", + "long_description": "Uses the OpenWorkGraph installation already running on this computer. v0.97 adds named Gateway administrators, an employee roster, identity-bound personal invitations, optional company sign-in, and an employee /me view of organization-held evidence and recorded reads. v0.96 added an evidence-driven first-value dashboard layer that reconstructs the current session from existing privacy-hardened evidence without adding sensors, permissions, AI access, retention, or MCP capabilities. v0.95 added a framework-neutral custom-harness setup flow plus standalone Python and Node helpers for privacy-safe structural agent telemetry; arbitrary MCP-capable harnesses can separately read authorized OpenWorkGraph context. v0.94 added explicit local history retention, separate saved-history AI access, lightweight history navigation, and structural browser-agent lifecycle observation. Canonical workflow evidence remains primary; Context Pulse provides incremental factual updates. Retained history is separately user-controlled: list_history can navigate saved human and agent sessions only while a time-limited saved-history lease is active. The legacy 24-tool MCP entrypoint remains available for existing configurations while new connections use this compact surface. Prompts, model responses, tool arguments/results, typed text, clipboard contents, exception text, returned values, and hidden reasoning are not captured by the custom agent helpers.", "author": {"name": "Koyar Afrasyab / Kinvectum"}, "repository": {"type": "git", "url": "https://github.com/KAVentures/openworkgraph"}, "server": {"type": "node", "entry_point": "server/index.js", "mcp_config": {"command": "node", "args": ["${__dirname}/server/index.js"], "env": {}}}, diff --git a/pyproject.toml b/pyproject.toml index ea4d63cb..3963b605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "workflow-observer" -version = "0.95.0" +version = "0.97.0" description = "Local-first work evidence, self-hosted organizational context gateway, REST API, and MCP access." requires-python = ">=3.11" license = {file = "LICENSE"} @@ -44,6 +44,6 @@ where = ["."] # Bundled name lists for contextual AI-context redaction (US Census 1990: public # domain; Statistics Sweden 2022: CC0). See server/name_lexicon/README.md. server = ["name_lexicon/*.txt", "name_lexicon/README.md"] -# The enterprise Gateway serves this shell at /admin. Every data/action request -# still requires the separately configured Gateway admin credential. -gateway = ["admin_console.html"] +# The enterprise Gateway serves these identity/admin shells. Every data/action +# request is still authenticated independently by the Gateway. +gateway = ["admin_console.html", "me_page.html", "join_verify.html", "landing.html"] diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 0e39a70d..9a013fdd 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openworkgraph-agent" -version = "0.95.0" +version = "0.97.0" description = "Dependency-free structural telemetry helper for custom OpenWorkGraph agent harnesses" requires-python = ">=3.10" license = {text = "Apache-2.0"} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 8efd3b82..74da4fa9 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@openworkgraph/agent", - "version": "0.95.0", + "version": "0.97.0", "description": "Dependency-free structural telemetry helper for custom OpenWorkGraph agent harnesses", "type": "module", "exports": { diff --git a/server/enterprise_runner.py b/server/enterprise_runner.py index 11a70392..a96f36ae 100644 --- a/server/enterprise_runner.py +++ b/server/enterprise_runner.py @@ -25,6 +25,7 @@ def main() -> None: import server.browser_agent_projection # noqa: F401 import server.agent_dashboard_control_plane # noqa: F401 import server.custom_harness_control_plane # noqa: F401 + import server.first_value_activation # noqa: F401 import server.org_join_routes as org_join_routes import server.dashboard_privacy # noqa: F401 diff --git a/server/first_value_activation.py b/server/first_value_activation.py new file mode 100644 index 00000000..b24d2832 --- /dev/null +++ b/server/first_value_activation.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from fastapi import Request, Response +from fastapi.responses import HTMLResponse + +from .main import ROOT +from .secure_app import app + + +SCRIPT_TAG = '' + + +@app.get("/first-value-activation.js", include_in_schema=False) +def first_value_activation_javascript() -> Response: + """Serve the additive first-value dashboard layer. + + The script reads only existing authenticated local endpoints. It adds no + capture sensor, data store, AI permission, retention permission, or MCP + capability. + """ + path = ROOT / "dashboard" / "first_value_activation.js" + return Response( + path.read_text(encoding="utf-8"), + media_type="application/javascript", + headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}, + ) + + +@app.middleware("http") +async def inject_first_value_activation(request: Request, call_next): + response = await call_next(request) + if request.method.upper() != "GET" or request.url.path != "/" or response.status_code != 200: + return response + if "text/html" not in str(response.headers.get("content-type") or "").lower(): + return response + try: + if hasattr(response, "body_iterator"): + chunks = [chunk async for chunk in response.body_iterator] + body = b"".join( + chunk if isinstance(chunk, bytes) else str(chunk).encode("utf-8") + for chunk in chunks + ) + else: + body = bytes(getattr(response, "body", b"")) + text = body.decode("utf-8") + except Exception: + return response + if SCRIPT_TAG not in text: + text = text.replace("", SCRIPT_TAG + "\n") + headers = dict(response.headers) + headers.pop("content-length", None) + return HTMLResponse(text, status_code=response.status_code, headers=headers) + + +__all__ = ["first_value_activation_javascript"] diff --git a/server/org_join_routes.py b/server/org_join_routes.py index 95a9ad83..32f3ad8f 100644 --- a/server/org_join_routes.py +++ b/server/org_join_routes.py @@ -36,7 +36,8 @@ class JoinCodeRequest(BaseModel): class JoinRequest(BaseModel): join_code: str - actor_id: str + # Ignored for personal invitations: the Gateway binds the invited employee. + actor_id: str = "" accept_sharing: bool = False @@ -63,14 +64,26 @@ def _preview(code: str) -> dict[str, Any]: organization_id = str(data.get("organization_id") or info["organization_id"]) if organization_id != info["organization_id"]: raise HTTPException(status_code=400, detail="The Gateway returned a different organization than the join code") + identity = data.get("identity") if isinstance(data.get("identity"), dict) else {"locked": False} + identity = dict(identity) + if identity.get("locked") and identity.get("require_sso"): + # Built from the Gateway address in the join code, never from Gateway-supplied URLs. + from urllib.parse import quote + identity["verify_url"] = f"{info['gateway_url']}/join/verify#code={quote(re_code(code))}" + identity.pop("verify_path", None) return { "organization_name": str(data.get("organization_name") or info["organization_name"]), "organization_id": organization_id, "gateway_url": info["gateway_url"], + "identity": identity, **{k: data.get(k) for k in ("expires_at", "seats_left", "sharing", "never_shared", "you_can")}, } +def re_code(code: str) -> str: + return "".join(str(code or "").split()) + + def _join(code: str, actor_id: str) -> dict[str, Any]: info = decode_join_code(code) actor = str(actor_id or "").strip() @@ -110,7 +123,17 @@ def org_join(request: JoinRequest) -> dict[str, Any]: # immediately before the enrollment request is made. with _lock: preview = _preview(request.join_code) - result = _join(request.join_code, request.actor_id) + identity = preview.get("identity") or {} + if identity.get("locked"): + if identity.get("require_sso") and not identity.get("sso_verified"): + raise HTTPException( + status_code=400, + detail="Confirm it's you with your company account first (use the Confirm button), then join.", + ) + actor = str(identity.get("actor_id") or identity.get("email") or "") + else: + actor = request.actor_id + result = _join(request.join_code, actor) return {**result, "organization_name": preview["organization_name"], **_combined_status()} except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -120,6 +143,36 @@ def org_join(request: JoinRequest) -> dict[str, Any]: raise HTTPException(status_code=502, detail=f"Joining failed: {str(exc)[:300]}") from exc +@app.post("/v1/org-me-link") +def org_me_link() -> dict[str, Any]: + """One-time link to the Gateway page showing what the organization holds about you.""" + from connector.config import load_device_token, load_gateway_settings + from connector.control import _paths + + if _demo_mode(): + raise HTTPException(status_code=409, detail="Not available in demo mode") + _data_dir, auth_dir = _paths(CONFIG_PATH) + settings = load_gateway_settings(CONFIG_PATH, auth_dir=auth_dir) + token = load_device_token(settings) + if not settings.enabled or not settings.url or not token: + raise HTTPException(status_code=409, detail="This computer is not connected to an organization") + try: + with httpx.Client(timeout=10.0, verify=settings.verify_tls) as client: + response = client.post(f"{settings.url}/v1/devices/me-link", headers={"Authorization": f"Bearer {token}"}) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Could not reach your organization's Gateway at {settings.url}") from exc + if response.status_code != 200: + try: + detail = response.json().get("detail") + except Exception: + detail = None + raise HTTPException(status_code=502, detail=detail or "The Gateway did not accept this computer") + path = str(response.json().get("path") or "") + if not path.startswith("/me#code="): + raise HTTPException(status_code=502, detail="Unexpected response from the Gateway") + return {"url": settings.url.rstrip("/") + path, "expires_in_seconds": response.json().get("expires_in_seconds")} + + # --- Managed (zero-touch) setup ------------------------------------------------------- def managed_config_path() -> Path: diff --git a/shared/join_code.py b/shared/join_code.py index 9b6ea08b..a2c88b66 100644 --- a/shared/join_code.py +++ b/shared/join_code.py @@ -16,7 +16,8 @@ PREFIX = "owgjoin1." _MAX_CODE_CHARS = 4096 -_TOKEN_RE = re.compile(r"^owg_enroll_link_[A-Za-z0-9._~-]{16,400}$") +# Reusable organization links and personal (identity-bound) invitations. +_TOKEN_RE = re.compile(r"^owg_enroll_(?:link|person)_[A-Za-z0-9._~-]{16,400}$") _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} diff --git a/tests/gateway_identity_helpers.py b/tests/gateway_identity_helpers.py new file mode 100644 index 00000000..4842c255 --- /dev/null +++ b/tests/gateway_identity_helpers.py @@ -0,0 +1,94 @@ +"""Shared helpers for the Gateway identity tests (admins, roster, SSO, /me).""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + +from gateway.admin_accounts import _totp_at +from gateway.hardening import HardeningSettings, PooledGatewayDB +from gateway.human_access import HumanAccessSettings +from gateway.human_enterprise_app import create_human_enterprise_app +from gateway.settings import GatewaySettings + +ADMIN_TOKEN = "admin-" + "x" * 40 +LEGACY_ENROLL = "enroll-" + "y" * 40 +BOOT = {"Authorization": f"Bearer {ADMIN_TOKEN}"} +PASSWORD = "correct horse battery" +GATEWAY = "https://gw.acme.test" + + +def make_app(tmp_path: Path, **kwargs: Any): + url = f"sqlite:///{tmp_path / 'gateway.db'}" + db = PooledGatewayDB(url) + app = create_human_enterprise_app( + settings=GatewaySettings(database_url=url, admin_token=ADMIN_TOKEN, enrollment_token=LEGACY_ENROLL), + hardening=HardeningSettings(), + human_access=kwargs.pop("human_access", HumanAccessSettings()), + db=db, + **kwargs, + ) + return app, db + + +class Totp: + """Produces a fresh, not-yet-used authenticator code for a secret.""" + + def __init__(self, secret: str) -> None: + self.secret = secret + self.step = int(time.time() // 30) - 1 + + def next(self) -> str: + self.step += 1 + return _totp_at(self.secret, self.step) + + +def bootstrap_owner(client: TestClient, email: str = "owner@acme.se") -> tuple[dict[str, str], Totp]: + created = client.post("/v1/admin/auth/bootstrap", json={"bootstrap_token": ADMIN_TOKEN, "email": email, "display_name": "Olga Owner"}) + assert created.status_code == 200, created.text + return activate(client, created.json()["setup_token"]) + + +def activate(client: TestClient, setup_token: str, password: str = PASSWORD) -> tuple[dict[str, str], Totp]: + started = client.post("/v1/admin/auth/setup/start", json={"setup_token": setup_token}) + assert started.status_code == 200, started.text + totp = Totp(started.json()["totp_secret"]) + done = client.post("/v1/admin/auth/setup/complete", json={"setup_token": setup_token, "password": password, "totp_code": totp.next()}) + assert done.status_code == 200, done.text + return {"Authorization": "Bearer " + done.json()["session_token"]}, totp + + +def add_admin(client: TestClient, owner: dict[str, str], email: str, role: str) -> tuple[dict[str, str], Totp, str]: + created = client.post("/v1/admin/accounts", headers=owner, json={"email": email, "display_name": email.split("@")[0], "role": role}) + assert created.status_code == 200, created.text + headers, totp = activate(client, created.json()["setup_token"]) + return headers, totp, created.json()["admin_id"] + + +def add_employee(client: TestClient, admin: dict[str, str], email: str, teams: list[str] | None = None, org: str = "acme") -> dict[str, Any]: + r = client.post(f"/v1/admin/employees/{org}", headers=admin, json={"email": email, "display_name": email.split("@")[0].title(), "teams": teams or []}) + assert r.status_code == 200, r.text + return r.json() + + +def personal_invite(client: TestClient, admin: dict[str, str], employee_id: str, org: str = "acme", **body: Any) -> dict[str, Any]: + r = client.post(f"/v1/admin/employees/{org}/{employee_id}/invites", headers=admin, + json={"organization_name": "Acme AB", "gateway_url": GATEWAY, **body}) + assert r.status_code == 200, r.text + return r.json() + + +def enroll(client: TestClient, token: str, device: str, *, actor: str = "", org: str = "acme"): + return client.post("/v1/devices/enroll", headers={"Authorization": "Bearer " + token}, + json={"organization_id": org, "actor_id": actor, "device_id": device}) + + +def ingest(client: TestClient, device_token: str, event_id: str, title: str = "Inbox") -> None: + r = client.post("/v1/evidence/batch", headers={"Authorization": "Bearer " + device_token}, json={"events": [{ + "event_id": event_id, "observed_at": "2026-09-27T10:00:00Z", "event_type": "focus_span", + "app": "Outlook", "window_title": title, "duration_seconds": 5, + }]}) + assert r.status_code == 200, r.text diff --git a/tests/js/first_value_activation.test.mjs b/tests/js/first_value_activation.test.mjs new file mode 100644 index 00000000..77f27608 --- /dev/null +++ b/tests/js/first_value_activation.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const source = fs.readFileSync(path.resolve('dashboard/first_value_activation.js'), 'utf8'); + +test('first-value activation JavaScript parses standalone', () => { + assert.doesNotThrow(() => new Function(source)); +}); + +test('first-value activation is read-only and evidence driven', () => { + assert.match(source, /\/v1\/summary\?scope=current&limit=500/); + assert.match(source, /\/v1\/agent-execution-traces\?/); + assert.match(source, /\/v1\/ai-access/); + assert.match(source, /state\.ready/); + assert.match(source, /Observed evidence only/); + assert.doesNotMatch(source, /method:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i); +}); + +test('first-value activation never captures content or hidden reasoning', () => { + for (const forbidden of [ + 'getDisplayMedia(', 'getUserMedia(', 'clipboard.read(', 'clipboard.readText(', + '/agent-ingest/', '/v1/events', '/v1/context-events' + ]) assert.equal(source.includes(forbidden), false, forbidden); + assert.match(source, /does not infer intent or read hidden reasoning/); +}); + +test('first-value guide is dismissible and does not trap advanced navigation', () => { + assert.match(source, /owg_first_value_dismissed_v1/); + assert.match(source, /window\.activateTab\?\.\('evidence'\)/); + assert.match(source, /window\.activateTab\?\.\('connect'\)/); + assert.match(source, /window\.activateTab\?\.\('organization'\)/); +}); diff --git a/tests/js/gateway_identity_pages.test.mjs b/tests/js/gateway_identity_pages.test.mjs new file mode 100644 index 00000000..50b1f40b --- /dev/null +++ b/tests/js/gateway_identity_pages.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); +const scripts = (html) => [...html.matchAll(/