74 lines
8.3 KiB
JavaScript
74 lines
8.3 KiB
JavaScript
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(p.evidence.enabled&&!config.evidenceKeyFile)throw Object.assign(Error('Protected evidence storage must be configured before collection is enabled'),{status:400});
|
|
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};
|
|
}
|