Governance‑as‑JSON Starter Pack (policy.json + Make/n8n snippets)
Copy‑paste policy‑as‑data for solo operators: a versioned policy.json, JSONLogic gate, Make/n8n evaluators, unit tests, and provider routing examples (OpenAI domains, Bedrock modelId scopes, Vertex regional endpoints). Use it to fail closed before any API/LLM call.
Paste these into your stack, swap the [BRACKETS], and your flows will start failing closed by default. Workflow: store policy.json → evaluate with JSONLogic in a Code step → only if allow === true do you call any API/LLM. If not allowed, either stop or fork to a Slack approval path.
How to use this pack
- Copy the policy.json into a repo or key–value store; version it like code. 2) Drop one of the evaluator snippets (Make or n8n) before every API/LLM step. 3) Feed the evaluator a small request object describing the intended call (vendor, region, model, data classes, purpose, user consent). 4) Use the returned allow boolean to gate the route. 5) Run the provided unit tests to prove default‑deny and residency enforcement work.
Inputs expected by the evaluators:
- request.vendor: [openai|bedrock|vertex]
- request.endpoint: API endpoint or action, e.g., [chat.completions|embeddings|images]
- request.model_id: Provider model identifier (e.g., [gpt-4o-mini], [anthropic.claude-3-5-sonnet])
- request.geo: High‑level geography of the request/user [US|EU|UK|JP|...]
- request.vertex_region (if vendor=vertex): [us-central1|europe-west4|...]
- request.data_classes: Array of labels you attach to the payload (e.g., ["generic_text","internal_doc","payment_card"])
- request.purpose: Business purpose label, e.g., [support_response|classification|marketing_message]
- request.user_consent.processing: [true|false] for processing beyond strictly necessary
01) policy.json — copy, fill, commit
No comments; valid JSON. Use [BRACKETS] to fill in values. Default‑deny is on; only explicitly allowed combinations will pass.
{
"version": "1.0.0",
"org": "[ORG_NAME]",
"default_deny": true,
"consent": {
"processing": [true|false],
"marketing": [true|false],
"training_opt_in": [true|false]
},
"data_classes": {
"allowed": ["generic_text", "internal_doc", "anonymized"],
"denied": ["payment_card", "health_phi", "government_id", "ssn"]
},
"regions": {
"allowed_geos": ["US", "EU"],
"user_region": "[US|EU|UK|JP|...]",
"storage_scope": "[US|EU]",
"inference_scope": "[US|EU|GLOBAL|GEO]",
"fallback": "deny"
},
"purposes": {
"allowed": ["support_response", "draft_summary", "classification"],
"requires_consent": ["marketing_message", "model_training"]
},
"retention": {
"logs_days": 30,
"request_payload_mask": ["email", "phone"],
"store_responses": false
},
"tools": {
"openai": {
"allowed": true,
"domains": { "US": "us.api.openai.com", "EU": "eu.api.openai.com" },
"endpoints_allowed": ["chat.completions", "embeddings"],
"models_allowed_by_region": {
"US": ["[OPENAI_MODEL_ID_US]"],
"EU": ["[OPENAI_MODEL_ID_EU]"]
}
},
"bedrock": {
"allowed": true,
"routing_mode": "[IN_REGION|GEOGRAPHIC|GLOBAL]",
"region": "[us-east-1|us-west-2|eu-central-1|eu-west-1]",
"geo_prefix_by_geo": { "US": "us.", "EU": "eu.", "GLOBAL": "global." },
"models_allowed": ["anthropic.claude-3-5-sonnet", "[BEDROCK_MODEL_ID]"]
},
"vertex": {
"allowed": true,
"region": "[us-central1|us-east5|europe-west4|europe-west3]",
"publishers_allowed": ["google", "meta", "anthropic"],
"models_allowed": ["google/text-bison", "[PUBLISHER/MODEL]"]
}
}
}
02) rules.jsonlogic — final gate expression
Keep JSONLogic simple and portable. We compute booleans in code and let JSONLogic make the final allow/deny decision.
{
"allow": {
"and": [
{"==": [ {"var": "resolved.vendor_allowed" }, true ]},
{"==": [ {"var": "resolved.region_ok" }, true ]},
{"==": [ {"var": "resolved.endpoint_ok" }, true ]},
{"==": [ {"var": "resolved.model_ok" }, true ]},
{"==": [ {"var": "resolved.consent_ok" }, true ]},
{"==": [ {"var": "resolved.data_denied" }, false ]},
{"in": [ {"var": "request.purpose" }, {"var": "policy.purposes.allowed"} ]}
]
}
}
03) Make Code app — JSONLogic evaluator (pasteable)
JavaScript, sub‑second, near‑free. Paste into a Make Code step placed immediately before any API/LLM call. Replace the stubbed policy and request with your real sources.
// Make Code — Governance gate (JSONLogic-style, minimal evaluator included)
// Inputs: set or fetch `policy` and `request` below. Return decides routing.
// 1) Load policy (example: hard-coded; replace with Data Store/HTTP fetch)
const policy = JSON.parse(`REPLACE_WITH_POLICY_JSON_STRING`);
// 2) Build the intended call request from upstream data
const request = {
vendor: "[openai|bedrock|vertex]",
endpoint: "[chat.completions|embeddings|...]",
model_id: "[MODEL_ID]",
geo: "[US|EU]",
vertex_region: "[us-central1|europe-west4]",
data_classes: ["generic_text"],
purpose: "support_response",
user_consent: { processing: true }
};
// 3) Tiny JSONLogic evaluator with only ops we use
const ops = {
"==": (a, b) => a === b,
"in": (needle, hay) => Array.isArray(hay) ? hay.includes(needle) : (typeof hay === 'string' ? hay.indexOf(needle) > -1 : false),
"and": (...args) => args.every(Boolean)
};
function jv(expr, ctx) { // evaluate value or var
if (expr && typeof expr === 'object' && expr.var) {
const path = expr.var.split('.');
return path.reduce((o, k) => (o == null ? undefined : o[k]), ctx);
}
return expr;
}
function jrun(rule, ctx) {
if (rule == null || typeof rule !== 'object') return rule;
const key = Object.keys(rule)[0];
if (!key) return rule;
if (key === 'and') return ops.and(...rule.and.map(r => jrun(r, ctx)));
if (key === 'in') return ops.in(jrun(rule.in[0], ctx), jrun(rule.in[1], ctx));
if (key === '==') return ops["=="](jrun(rule["=="][0], ctx), jrun(rule["=="][1], ctx));
return false; // unknown op → safe deny
}
// 4) Derive booleans required by rules
function geoOk(policy, req) {
return policy.regions.allowed_geos.includes(req.geo);
}
function consentOk(policy, req) {
const needs = policy.purposes.requires_consent.includes(req.purpose);
return needs ? req.user_consent?.processing === true && policy.consent.processing === true : true;
}
function vendorAllowed(policy, req) {
return policy.tools[req.vendor]?.allowed === true;
}
function endpointOk(policy, req) {
if (req.vendor === 'openai') return policy.tools.openai.endpoints_allowed.includes(req.endpoint);
return true; // endpoints vary; tighten if needed
}
function modelOk(policy, req) {
if (req.vendor === 'openai') {
const dom = policy.tools.openai.domains[req.geo];
const allowed = policy.tools.openai.models_allowed_by_region[req.geo] || [];
return Boolean(dom) && allowed.includes(req.model_id);
}
if (req.vendor === 'bedrock') {
const t = policy.tools.bedrock;
const baseOk = t.models_allowed.includes(req.model_id.replace(/^((us|eu|global)\.)/, ''));
if (!baseOk) return false;
const want = (t.routing_mode === 'GLOBAL') ? 'global.' : (t.routing_mode === 'GEOGRAPHIC' ? t.geo_prefix_by_geo[req.geo] : '');
// IN_REGION may use regional IDs without geo prefix; accept either direct regional ID or prefixed geo when applicable
const hasPrefix = /^(us\.|eu\.|global\.)/.test(req.model_id) ? req.model_id.startsWith(want || t.geo_prefix_by_geo[req.geo] || '') : t.region.startsWith(req.geo.toLowerCase());
return Boolean(hasPrefix);
}
if (req.vendor === 'vertex') {
const t = policy.tools.vertex;
const regionOk = req.vertex_region === t.region;
const modelOk = t.models_allowed.includes(req.model_id);
return regionOk && modelOk;
}
return false;
}
const resolved = {
vendor_allowed: vendorAllowed(policy, request),
region_ok: geoOk(policy, request),
endpoint_ok: endpointOk(policy, request),
model_ok: modelOk(policy, request),
consent_ok: consentOk(policy, request),
data_denied: request.data_classes.some(dc => policy.data_classes.denied.includes(dc))
};
// 5) Final decision via JSONLogic rule
const rules = {
allow: {
and: [
{ "==": [ { var: "resolved.vendor_allowed" }, true ] },
{ "==": [ { var: "resolved.region_ok" }, true ] },
{ "==": [ { var: "resolved.endpoint_ok" }, true ] },
{ "==": [ { var: "resolved.model_ok" }, true ] },
{ "==": [ { var: "resolved.consent_ok" }, true ] },
{ "==": [ { var: "resolved.data_denied" }, false ] },
{ in: [ { var: "request.purpose" }, { var: "policy.purposes.allowed" } ] }
]
}
};
const allow = jrun(rules.allow, { policy, request, resolved }) === true;
const reason = allow ? "ALLOW" : "DENY: policy gate failed";
return { allow, reason, resolved };
Routing: In Make, put a Filter on the next module: allow equals true. Add a fallback route that stops or goes to Slack approval.
04) n8n Code node — evaluator (Cloud‑safe)
Works in n8n Cloud (no npm). Place this Code node before an API/LLM node. It annotates each item with policy_decision.allow.
// n8n Code — Governance gate for each item
// Assumes: previous nodes provide `policy` (JSON) and a `request` object per item.
const items = $input.all();
const policy = JSON.parse(`REPLACE_WITH_POLICY_JSON_STRING`);
function decide(policy, request){
const resolved = {
vendor_allowed: policy.tools[request.vendor]?.allowed === true,
region_ok: policy.regions.allowed_geos.includes(request.geo),
endpoint_ok: request.vendor === 'openai' ? policy.tools.openai.endpoints_allowed.includes(request.endpoint) : true,
model_ok: (function(){
if (request.vendor === 'openai') {
const allowed = policy.tools.openai.models_allowed_by_region[request.geo] || [];
return Boolean(policy.tools.openai.domains[request.geo]) && allowed.includes(request.model_id);
}
if (request.vendor === 'bedrock') {
const t = policy.tools.bedrock;
const baseOk = t.models_allowed.includes(request.model_id.replace(/^((us|eu|global)\.)/, ''));
if (!baseOk) return false;
const want = (t.routing_mode === 'GLOBAL') ? 'global.' : (t.routing_mode === 'GEOGRAPHIC' ? t.geo_prefix_by_geo[request.geo] : '');
const hasPrefix = /^(us\.|eu\.|global\.)/.test(request.model_id) ? request.model_id.startsWith(want || t.geo_prefix_by_geo[request.geo] || '') : t.region.startsWith(request.geo.toLowerCase());
return Boolean(hasPrefix);
}
if (request.vendor === 'vertex') {
const t = policy.tools.vertex;
return request.vertex_region === t.region && t.models_allowed.includes(request.model_id);
}
return false;
})(),
consent_ok: (function(){
const needs = policy.purposes.requires_consent.includes(request.purpose);
return needs ? request.user_consent?.processing === true && policy.consent.processing === true : true;
})(),
data_denied: request.data_classes.some(dc => policy.data_classes.denied.includes(dc))
};
const allow = (
resolved.vendor_allowed &&
resolved.region_ok &&
resolved.endpoint_ok &&
resolved.model_ok &&
resolved.consent_ok &&
!resolved.data_denied &&
policy.purposes.allowed.includes(request.purpose)
);
return { allow, resolved, reason: allow ? 'ALLOW' : 'DENY: policy gate failed' };
}
return items.map(item => {
const request = item.json.request; // attach your request shape here
const decision = decide(policy, request);
return { json: { ...item.json, policy_decision: decision } };
});
Routing: Add an IF node on {{$json.policy_decision.allow}} → true: call API; false: stop or send to Slack approval (template linked in episode notes).
05) Provider routing helpers — OpenAI, Bedrock, Vertex
Use the policy to build provider‑correct targets. These examples assume the earlier gate has already returned allow === true.
OpenAI (regional base URL):
const domain = policy.tools.openai.domains[request.geo];
if (!domain) throw new Error('No OpenAI domain for geo');
const openaiBase = `https://${domain}/v1`;
// Example: POST ${openaiBase}/chat/completions
Amazon Bedrock (modelId scope):
// Example targets based on routing_mode
// In‑Region EU (strict):
const bedrockModelEU = `eu.${request.model_id}`; // e.g., eu.anthropic.claude-3-5-sonnet
// Geographic US (kept within US geos):
const bedrockModelUS = `us.${request.model_id}`; // e.g., us.anthropic.claude-3-5-sonnet
// Global (no geo restriction):
const bedrockModelGlobal = `global.${request.model_id}`;
Vertex AI (regional endpoint):
const region = policy.tools.vertex.region; // e.g., 'europe-west4'
const vertexUrl = `https://${region}-aiplatform.googleapis.com/v1/projects/[PROJECT_ID]/locations/${region}/publishers/[PUBLISHER]/models/[MODEL]:generateContent`;
06) Unit tests — sample cases + harness
Copy these into a separate tests.json and run them with the harness below (Make or n8n). Each case asserts allow/deny and exercises region/tools/consent.
[
{
"name": "EU user → OpenAI EU, allowed model, necessary processing",
"request": {
"vendor": "openai",
"endpoint": "chat.completions",
"model_id": "[OPENAI_MODEL_ID_EU]",
"geo": "EU",
"data_classes": ["generic_text"],
"purpose": "support_response",
"user_consent": { "processing": false }
},
"expect_allow": true
},
{
"name": "US marketing without consent → deny",
"request": {
"vendor": "openai",
"endpoint": "chat.completions",
"model_id": "[OPENAI_MODEL_ID_US]",
"geo": "US",
"data_classes": ["generic_text"],
"purpose": "marketing_message",
"user_consent": { "processing": false }
},
"expect_allow": false
},
{
"name": "EU inference via Bedrock with geo prefix mismatch → deny",
"request": {
"vendor": "bedrock",
"endpoint": "invoke",
"model_id": "us.anthropic.claude-3-5-sonnet",
"geo": "EU",
"data_classes": ["generic_text"],
"purpose": "draft_summary",
"user_consent": { "processing": true }
},
"expect_allow": false
},
{
"name": "Vertex EU region mismatch → deny",
"request": {
"vendor": "vertex",
"endpoint": "generateContent",
"model_id": "google/text-bison",
"geo": "EU",
"vertex_region": "us-central1",
"data_classes": ["generic_text"],
"purpose": "classification",
"user_consent": { "processing": true }
},
"expect_allow": false
},
{
"name": "Denied data class present → deny",
"request": {
"vendor": "openai",
"endpoint": "embeddings",
"model_id": "[OPENAI_MODEL_ID_US]",
"geo": "US",
"data_classes": ["payment_card"],
"purpose": "classification",
"user_consent": { "processing": true }
},
"expect_allow": false
}
]
Test harness (drop into Make/n8n instead of the normal evaluator to validate your policy):
const policy = JSON.parse(`REPLACE_WITH_POLICY_JSON_STRING`);
const tests = JSON.parse(`REPLACE_WITH_TESTS_JSON_STRING`);
let pass = 0, fail = 0; const results = [];
for (const t of tests) {
const decision = (function decide(policy, request){
const resolved = {
vendor_allowed: policy.tools[request.vendor]?.allowed === true,
region_ok: policy.regions.allowed_geos.includes(request.geo),
endpoint_ok: request.vendor === 'openai' ? policy.tools.openai.endpoints_allowed.includes(request.endpoint) : true,
model_ok: (function(){
if (request.vendor === 'openai') {
const allowed = policy.tools.openai.models_allowed_by_region[request.geo] || [];
return Boolean(policy.tools.openai.domains[request.geo]) && allowed.includes(request.model_id);
}
if (request.vendor === 'bedrock') {
const t = policy.tools.bedrock;
const baseOk = t.models_allowed.includes(request.model_id.replace(/^((us|eu|global)\.)/, ''));
if (!baseOk) return false;
const want = (t.routing_mode === 'GLOBAL') ? 'global.' : (t.routing_mode === 'GEOGRAPHIC' ? t.geo_prefix_by_geo[request.geo] : '');
const hasPrefix = /^(us\.|eu\.|global\.)/.test(request.model_id) ? request.model_id.startsWith(want || t.geo_prefix_by_geo[request.geo] || '') : t.region.startsWith(request.geo.toLowerCase());
return Boolean(hasPrefix);
}
if (request.vendor === 'vertex') {
const t = policy.tools.vertex;
return request.vertex_region === t.region && t.models_allowed.includes(request.model_id);
}
return false;
})(),
consent_ok: (function(){
const needs = policy.purposes.requires_consent.includes(request.purpose);
return needs ? request.user_consent?.processing === true && policy.consent.processing === true : true;
})(),
data_denied: request.data_classes.some(dc => policy.data_classes.denied.includes(dc))
};
const allow = (
resolved.vendor_allowed && resolved.region_ok && resolved.endpoint_ok && resolved.model_ok && resolved.consent_ok && !resolved.data_denied && policy.purposes.allowed.includes(request.purpose)
);
return { allow, resolved };
})(policy, t.request);
const ok = decision.allow === t.expect_allow;
ok ? pass++ : fail++;
results.push({ name: t.name, expected: t.expect_allow, got: decision.allow, resolved: decision.resolved });
}
return { pass, fail, results };
07) Human override (Slack) — the only exception path
- Make: Add a fallback route after the gate with a Slack module (DM or channel) that posts the request summary and two buttons: Approve / Reject. On Approve, set
policy_override=trueand continue; on Reject, terminate with reason. Log overrides to a sheet with timestamp, approver, and request hash. - n8n: Use the official Slack template pattern (Approve/Reject) and merge back. Only the Approve path proceeds to the API node; the Reject path ends the execution.
- Both: keep overrides rare. Review weekly; aim for < 5% of runs needing human approval.