import {profilesForOperation,validateProfiles} from './user-profiles.mjs'; import {randomUUID} from 'node:crypto'; import {z} from 'zod'; import {PolicySchema} from '../../packages/core/policy.mjs'; import {digest} from './store.mjs'; import {globalBookmarksValid} from './bookmarks.mjs'; import {policyDiff} from './policy-history.mjs'; import {groupsForOperation,validateGroupConfiguration} from './policy-groups.mjs'; export function policyPublisher(app,store,config){ store.db.exec(`CREATE TABLE IF NOT EXISTS policy_reviews(id TEXT PRIMARY KEY,time TEXT NOT NULL,author TEXT NOT NULL,expected_version INTEGER NOT NULL,payload_hash TEXT NOT NULL,policy TEXT NOT NULL,kind TEXT NOT NULL,operation TEXT NOT NULL,status TEXT NOT NULL,reviewer TEXT,reviewed_at TEXT,published_version INTEGER)`); function validate(policy,expected,operation={}){ const p=PolicySchema.parse(policy); if(store.getRaw('policy').version!==expected||p.version!==expected+1)throw Object.assign(Error('Policy version conflict'),{status:409}); if(!globalBookmarksValid(store,p))throw Object.assign(Error('Company bookmark IDs conflict with personal bookmarks'),{status:409}); if(Date.parse(p.expiresAt)<=Date.now()||Date.parse(p.expiresAt)>Date.now()+30*86400000)throw Object.assign(Error('Policy expiry must be within the next 30 days'),{status:400}); validateGroupConfiguration(store,p,groupsForOperation(store,operation),operation); validateProfiles(store,p,operation,groupsForOperation(store,operation)); return p; } function apply(p,actor,kind,operation){ if(operation.type?.startsWith('profile-')||operation.configuration)store.put('userProfiles',profilesForOperation(store,operation.configuration?{type:'profile-restore',configuration:operation.configuration}:operation)); if(['group-upsert','group-delete','group-restore'].includes(operation.type))store.put('policyGroups',groupsForOperation(store,operation)); if(operation.type==='inspection'){ if(!store.db.prepare('UPDATE devices SET document_analysis_enabled=? WHERE id=? AND revoked=0').run(operation.enabled?1:0,operation.deviceId).changes)throw Object.assign(Error('Active device not found'),{status:409}); store.event(operation.deviceId,'inspection.setting-changed',{actor,version:p.version,documentAnalysisEnabled:operation.enabled}); } if(operation.type==='teaching'){ if(!store.db.prepare("UPDATE teaching SET status='published',reviewer=?,policy_version=? WHERE id=? AND status='draft'").run(actor,p.version,operation.id).changes)throw Object.assign(Error('Teaching draft no longer available'),{status:409}); store.event(null,'teaching.approved',{actor,referenceId:operation.id,version:p.version,coverage:operation.coverage,mode:operation.mode}); } store.put('policy',p);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(p.version,new Date().toISOString(),actor,JSON.stringify(p)); store.db.prepare('INSERT INTO policy_group_history(version,groups) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('policyGroups')||[])); store.db.prepare('INSERT INTO user_profile_history(version,configuration) VALUES(?,?)').run(p.version,JSON.stringify(store.getRaw('userProfiles')||{profiles:[],categories:[]})); store.event(null,kind,{actor,version:p.version,...(operation.sourceVersion?{sourceVersion:operation.sourceVersion}:{}),...(operation.type==='group-upsert'?{groupId:operation.group.id,stage:operation.group.rollout.stage,count:operation.group.memberDeviceIds.length}:{}),...(operation.type==='group-delete'?{groupId:operation.groupId}:{})}); } function prepare(p,author,kind='policy.published',operation={}){ p=validate(p,p.version-1,operation);const id=randomUUID(),payloadHash=digest(JSON.stringify({policy:p,kind,operation})),expectedVersion=p.version-1; if(store.db.prepare("SELECT COUNT(*) AS count FROM policy_reviews WHERE status='pending' AND expected_version=?").get(expectedVersion).count>=1000)throw Object.assign(Error('Pending policy review limit reached'),{status:409}); store.db.exec('BEGIN IMMEDIATE');try{ store.db.prepare('INSERT INTO policy_reviews(id,time,author,expected_version,payload_hash,policy,kind,operation,status) VALUES(?,?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),author,expectedVersion,payloadHash,JSON.stringify(p),kind,JSON.stringify(operation),'pending'); store.event(null,'policy.review-requested',{actor:author,reviewId:id,version:expectedVersion,payloadHash});store.db.exec('COMMIT'); }catch(e){store.db.exec('ROLLBACK');throw e;} return {reviewRequired:true,reviewId:id,status:'pending',expectedVersion,payloadHash}; } function submit(req,res,p,kind='policy.published',operation={},extra={}){ if(config.requireIndependentPolicyApproval)return res.status(202).json(prepare(p,req.principal.sub,kind,operation)); store.db.exec('BEGIN IMMEDIATE');try{p=validate(p,p.version-1,operation);apply(p,req.principal.sub,kind,operation);store.db.exec('COMMIT');}catch(e){store.db.exec('ROLLBACK');throw e;} return res.json({ok:true,version:p.version,...extra}); } const view=row=>({id:row.id,time:row.time,author:row.author,expectedVersion:row.expected_version,payloadHash:row.payload_hash,kind:row.kind,status:row.status==='pending'&&row.expected_version!==store.getRaw('policy').version?'stale':row.status,reviewer:row.reviewer,reviewedAt:row.reviewed_at,publishedVersion:row.published_version}); app.get('/api/admin/policy-reviews',(_req,res)=>res.json({requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval,reviews:store.db.prepare('SELECT * FROM policy_reviews ORDER BY time DESC LIMIT 100').all().map(view)})); app.get('/api/admin/policy-reviews/:id',(req,res)=>{const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id);if(!row)return res.status(404).json({error:'Policy review not found'});const policy=PolicySchema.parse(JSON.parse(row.policy));res.json({...view(row),policy,operation:JSON.parse(row.operation),diff:policyDiff(store.get('policy'),policy)});}); app.post('/api/admin/policy-reviews',(req,res)=>{const b=z.object({policy:PolicySchema}).strict().parse(req.body);res.status(202).json(prepare(b.policy,req.principal.sub));}); for(const action of ['approve','reject'])app.post(`/api/admin/policy-reviews/:id/${action}`,(req,res)=>{ const b=(action==='approve'?z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),expectedVersion:z.number().int().positive(),approve:z.literal(true)}):z.object({payloadHash:z.string().regex(/^[a-f0-9]{64}$/),reject:z.literal(true)})).strict().parse(req.body); store.db.exec('BEGIN IMMEDIATE');try{ const row=store.db.prepare('SELECT * FROM policy_reviews WHERE id=?').get(req.params.id); if(!row)throw Object.assign(Error('Policy review not found'),{status:404}); if(row.author===req.principal.sub)throw Object.assign(Error('Independent administrator required'),{status:403}); if(row.status!=='pending'||b.payloadHash!==row.payload_hash)throw Object.assign(Error('Policy review conflict'),{status:409}); const operation=JSON.parse(row.operation),policy=PolicySchema.parse(JSON.parse(row.policy)); if(digest(JSON.stringify({policy,kind:row.kind,operation}))!==row.payload_hash)throw Object.assign(Error('Policy review integrity conflict'),{status:409}); if(action==='approve'){if(b.expectedVersion!==row.expected_version)throw Object.assign(Error('Policy version conflict'),{status:409});validate(policy,row.expected_version,operation);apply(policy,req.principal.sub,row.kind,operation);} const status=action==='approve'?'approved':'rejected'; store.db.prepare('UPDATE policy_reviews SET status=?,reviewer=?,reviewed_at=?,published_version=? WHERE id=?').run(status,req.principal.sub,new Date().toISOString(),action==='approve'?policy.version:null,row.id); store.event(null,`policy.review-${status}`,{actor:req.principal.sub,author:row.author,reviewId:row.id,payloadHash:row.payload_hash,version:action==='approve'?policy.version:row.expected_version}); store.db.exec('COMMIT');res.json({ok:true,id:row.id,status,...(action==='approve'?{version:policy.version}:{})}); }catch(e){store.db.exec('ROLLBACK');res.status(e.status||400).json({error:e.message});} }); return {submit}; }