Files
safetynet/packages/core/policy-groups.mjs
T

49 lines
3.9 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import {z} from 'zod';
import {SETTINGS_SECTIONS} from './managed-settings.mjs';
import {BookmarkItemsSchema} from './bookmarks.mjs';
const id=z.string().min(1).max(80).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
const unique=(schema,max)=>z.array(schema).max(max).refine(items=>new Set(items).size===items.length,'Values must be unique');
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
export const GroupRestrictionsSchema=z.object({
blockedHosts:unique(host,200).default([]),blockedPhrases:unique(z.string().min(1).max(4096),500).default([]),
disableDownloads:z.boolean().default(false),disableAI:z.boolean().default(false),disableAgents:z.boolean().default(false),
hideSettings:z.boolean().default(false),requireDocumentScan:z.boolean().default(false),
hiddenSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]),
lockedSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]),
managedBookmarks:BookmarkItemsSchema.default([])
}).strict();
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
2026-10-13 10:33:31 +02:00
export const PolicyGroupSchema=z.object({id,name:z.string().min(1).max(100).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),
memberDeviceIds:unique(id,10000),restrictions:GroupRestrictionsSchema,
rollout:z.object({stage:z.enum(['pilot','expanded','all']),deviceIds:unique(id,10000).default([])}).strict()
}).strict().superRefine((group,ctx)=>{
if(group.rollout.stage==='all'&&group.rollout.deviceIds.length)ctx.addIssue({code:'custom',message:'All-members rollout requires empty cohort'});
if(group.rollout.stage!=='all'&&(!group.rollout.deviceIds.length||group.rollout.deviceIds.some(i=>!group.memberDeviceIds.includes(i))))ctx.addIssue({code:'custom',message:'Pilot and expanded cohorts must be nonempty subsets of members'});
if(Buffer.byteLength(JSON.stringify(group))>250000)ctx.addIssue({code:'custom',message:'Group exceeds the 250 KB feature budget'});
});
export const GroupAssignmentSchema=z.object({deviceId:id,groups:unique(id,100),profiles:unique(id,100).optional()}).strict();
export function groupApplies(group,deviceId){return group.memberDeviceIds.includes(deviceId)&&(group.rollout.stage==='all'||group.rollout.deviceIds.includes(deviceId));}
// The company baseline always survives. Boolean restrictions combine with OR;
// block lists and hidden sections combine with a stable union, never replacement.
export function applyPolicyGroups(policy,groups,deviceId){
const result=structuredClone(policy),applied=groups.map(g=>PolicyGroupSchema.parse(g)).filter(g=>groupApplies(g,deviceId)).sort((a,b)=>a.id.localeCompare(b.id,'en'));
const union=(a,b)=>[...new Set([...a,...b])].sort();
for(const group of applied){const r=group.restrictions;
result.browser.blockedHosts=union(result.browser.blockedHosts,r.blockedHosts);
result.security.content.blockedPhrases=union(result.security.content.blockedPhrases,r.blockedPhrases);
if(r.disableDownloads)result.browser.allowDownloads=false;
if(r.disableAI)result.ai.enabled=false;
if(r.disableAgents)result.agents.enabled=false;
if(r.hideSettings)result.browser.settings.visible=false;
if(r.requireDocumentScan)result.inspection.documentAnalysisEnabled=true;
result.browser.settings.hiddenSections=union(result.browser.settings.hiddenSections,r.hiddenSections);
result.browser.settings.lockedSections=union(result.browser.settings.lockedSections,r.lockedSections);
for(const bookmark of r.managedBookmarks){const existing=result.browser.bookmarks.managed.find(b=>b.id===bookmark.id);
if(existing&&JSON.stringify(existing)!==JSON.stringify(bookmark))throw Error('Managed bookmark ID conflicts across policies');
if(!existing)result.browser.bookmarks.managed.push(bookmark);
}
}
result.assignment={deviceId,groups:applied.map(g=>g.id),...(result.assignment?.profiles?{profiles:result.assignment.profiles}:{})};
return result;
}