Lesson 03 / 21
Identity and Resource-Based Policies
The same access request is passed through two separate policy sets and their intersection is computed: how many requests are denied for being allowed in only one direction, why a resource-based policy becomes mandatory once the account boundary is crossed, and how many documents it takes to find out why an access was denied.
Published Updated
In this lesson
- Evaluates identity-based and resource-based policies separately over the same request set
- Counts the intersection of both directions: requests denied for being allowed in only one direction
- Measures how many documents it takes to find the reason for a denial, and how many requests looking at only one direction gets wrong
Contents
The previous lesson’s five documents all sat in one place, and all of them were attached to a single subject. The resource itself can carry a policy too: a document written onto the store says who can reach it, from the resource’s own side. Two directions answer the same question from two separate places — one asks “where can this subject reach,” the other “who can reach this resource” — and a request passes through both.
The two directions exist for different reasons. The identity-based document sits with the team that manages the subject, the resource-based document with the team that operates the resource. Inside the same account the two can substitute for each other; once the account boundary is crossed, they cannot. This lesson’s measure is the size of the intersection and the cost of reading it.
- CI13. The model carries eight subjects, five actions, twenty-one resources, and three
accounts. No real account is run; what is measured is the model built with
node. - CI14. The evaluation rule is defined within the lesson: within the same account one direction allowing is enough, once the account boundary is crossed both directions must allow, and an explicit deny wins in either direction.
- CI15. Eleven identity-based and six resource-based documents were written by hand. The numbers depend on this set; they are not the measurement’s shape.
- CI16. The thirty-day usage trace was written by hand and carries no environment split — a triple that appears in the trace counts as legitimate in all three environments. The unused permission count is therefore a lower bound.
- CI17. The “documents to check” count is the sum of the subject-bound documents, the resource-matching documents, and the account rule. In reality inherited documents grow the number further; the measured value is a lower bound.
- CI18. Organization structure was reduced to three accounts; accounts containing each other did not enter the model.
Two Directions
// direction.mjs — two evaluation directions; not a real account, only a model built with node export const ACTION = ['read', 'list', 'write', 'delete', 'configure']; const ENV = ['development', 'staging', 'production']; export const TYPE_ACCOUNT = { // which account a resource type lives in 'measurement-store': 'account-measurement', 'record-stream': 'account-measurement', 'edge-node-group': 'account-measurement', 'object-store': 'account-analytics', 'function-package': 'account-analytics', 'machine-cluster': 'account-shared', 'network-config': 'account-shared', }; export const SUBJECT_ACCOUNT = { 'ingest-service': 'account-measurement', 'validation-service': 'account-measurement', 'edge-collector': 'account-measurement', 'analytics-job': 'account-analytics', 'dashboard-reader': 'account-analytics', 'billing-job': 'account-shared', 'field-maintenance': 'account-shared', 'backup-verification': 'account-shared', }; export const SUBJECT = Object.keys(SUBJECT_ACCOUNT); export const RESOURCE = Object.keys(TYPE_ACCOUNT).flatMap((t) => ENV.map((e) => ({ name: `${t}/${e}`, type: t, account: TYPE_ACCOUNT[t] }))); export const REQUEST = SUBJECT.flatMap((s) => ACTION.flatMap((a) => RESOURCE.map((r) => ({ subject: s, action: a, resource: r })))); const READ = ['read', 'list'], READ_WRITE = ['read', 'list', 'write']; const ID = (name, subject, effect, action, resource) => ({ name, subject, effect, action, resource }); const RD = (name, resource, effect, subject, action) => ({ name, resource, effect, subject, action }); export const IDENTITY_DOCUMENT = [ // attached to a subject: where it can do what ID('k1', 'ingest-service', 'allow', READ_WRITE, ['measurement-store/*', 'record-stream/*']), ID('k2', 'ingest-service', 'deny', ['delete'], ['*']), ID('k3', 'validation-service', 'allow', READ, ['*']), ID('k4', 'edge-collector', 'allow', READ_WRITE, ['edge-node-group/*', 'record-stream/*']), ID('k5', 'analytics-job', 'allow', READ, ['measurement-store/*', 'object-store/*']), ID('k6', 'analytics-job', 'allow', ['write'], ['object-store/staging', 'object-store/development']), ID('k7', 'dashboard-reader', 'allow', READ, ['measurement-store/*', 'record-stream/*']), ID('k8', 'billing-job', 'allow', READ, ['measurement-store/*', 'object-store/*', 'record-stream/*']), ID('k9', 'field-maintenance', 'allow', [...READ_WRITE, 'configure'], ['machine-cluster/*', 'network-config/*', 'edge-node-group/*']), ID('k10', 'field-maintenance', 'deny', ['delete'], ['*/production']), ID('k11', 'backup-verification', 'allow', READ, ['object-store/*', 'measurement-store/*']), ]; export const RESOURCE_DOCUMENT = [ // attached to a resource: who can do what RD('r1', ['measurement-store/*'], 'allow', ['analytics-job', 'billing-job'], READ), RD('r2', ['object-store/*'], 'allow', ['*'], READ), RD('r3', ['record-stream/*'], 'allow', ['ingest-service', 'edge-collector'], READ_WRITE), RD('r4', ['machine-cluster/*', 'network-config/*'], 'allow', ['field-maintenance'], [...READ_WRITE, 'configure']), RD('r5', ['*/production'], 'deny', ['dashboard-reader'], ['write', 'delete', 'configure']), RD('r6', ['edge-node-group/*'], 'allow', ['edge-collector', 'field-maintenance'], READ_WRITE), ]; // Thirty-day usage trace: (subject, action, resource type); written by hand, carries no environment split export const TRACE = new Set([ ['ingest-service', READ_WRITE, ['measurement-store', 'record-stream']], ['validation-service', ['read'], ['measurement-store', 'record-stream', 'machine-cluster']], ['edge-collector', ['read', 'write'], ['edge-node-group', 'record-stream']], ['analytics-job', READ, ['measurement-store', 'object-store']], ['dashboard-reader', READ, ['measurement-store', 'record-stream']], ['billing-job', ['read'], ['measurement-store', 'object-store', 'record-stream']], ['field-maintenance', ['read', 'write', 'configure'], ['machine-cluster', 'network-config']], ['backup-verification', ['read'], ['object-store', 'measurement-store']], ].flatMap(([s, as, ts]) => as.flatMap((a) => ts.map((t) => `${s}|${a}|${t}`)))); export const inTrace = (i) => TRACE.has(`${i.subject}|${i.action}|${i.resource.type}`); export const pattern = (p, s) => new RegExp(`^${p.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`).test(s); export const identityDocuments = (i, kb = IDENTITY_DOCUMENT) => kb.filter((b) => b.subject === i.subject); export const resourceDocuments = (i, rb = RESOURCE_DOCUMENT) => rb.filter((b) => b.resource.some((p) => pattern(p, i.resource.name))); const matches = (b, i) => b.action.includes(i.action) && b.resource.some((p) => pattern(p, i.resource.name)) && (typeof b.subject === 'string' ? b.subject === i.subject : b.subject.includes('*') || b.subject.includes(i.subject)); export function direction(documents, i) { // one direction's outcome const m = documents.filter((b) => matches(b, i)); if (m.some((b) => b.effect === 'deny')) return 'explicit-deny'; return m.some((b) => b.effect === 'allow') ? 'allow' : 'no-match'; } // Model rule: within the same account one direction is enough, across the account boundary both must allow export function evaluate(i, kb = IDENTITY_DOCUMENT, rb = RESOURCE_DOCUMENT) { const a = direction(identityDocuments(i, kb), i), b = direction(resourceDocuments(i, rb), i); if (a === 'explicit-deny' || b === 'explicit-deny') return 'explicit-deny'; const same = SUBJECT_ACCOUNT[i.subject] === i.resource.account; return (same ? a === 'allow' || b === 'allow' : a === 'allow' && b === 'allow') ? 'allow' : 'implicit-deny'; }
The two document types share the same fields but carry different lists: the identity-based
document is bound to a subject and carries a resource pattern, the resource-based document is
bound to a resource and carries a subject list. The * subject list in document r2 is the
most visible place this difference shows — instead of naming who can reach it, the resource can
open itself to everyone.
Intersection
// run1.mjs — the same request is passed through both directions and the intersection is computed import { REQUEST, SUBJECT, RESOURCE, ACTION, SUBJECT_ACCOUNT, IDENTITY_DOCUMENT, RESOURCE_DOCUMENT, identityDocuments, resourceDocuments, direction, evaluate } from './direction.mjs'; const sameAccount = (i) => SUBJECT_ACCOUNT[i.subject] === i.resource.account; const directions = REQUEST.map((i) => ({ i, a: direction(identityDocuments(i), i), b: direction(resourceDocuments(i), i), same: sameAccount(i), result: evaluate(i) })); console.log(`${SUBJECT.length} subjects x ${ACTION.length} actions x ${RESOURCE.length} resources = ${REQUEST.length} requests`); console.log(`identity-based documents ${IDENTITY_DOCUMENT.length}, resource-based documents ${RESOURCE_DOCUMENT.length}`); console.log(`same account ${directions.filter((y) => y.same).length}, crossing the account boundary ${directions.filter((y) => !y.same).length}\n`); const count = (f) => directions.filter(f).length; console.log('both directions compared same account account boundary allowed result'); const row = (name, f) => console.log(`${name.padEnd(27)} ${String(count((y) => y.same && f(y))).padEnd(13)} ` + `${String(count((y) => !y.same && f(y))).padEnd(17)} ${count((y) => f(y) && y.result === 'allow')}`); row('identity-only allow', (y) => y.a === 'allow' && y.b !== 'allow' && y.b !== 'explicit-deny'); row('resource-only allow', (y) => y.b === 'allow' && y.a !== 'allow' && y.a !== 'explicit-deny'); row('both directions allow', (y) => y.a === 'allow' && y.b === 'allow'); row('neither direction allows', (y) => y.a !== 'allow' && y.b !== 'allow'); row('either direction denies', (y) => y.a === 'explicit-deny' || y.b === 'explicit-deny'); const s = { allow: 0, 'explicit-deny': 0, 'implicit-deny': 0 }; for (const y of directions) s[y.result]++; console.log(`\nresult: allow ${s.allow}, explicit deny ${s['explicit-deny']}, implicit deny ${s['implicit-deny']}`); const oneDirectionWasEnough = count((y) => y.result !== 'allow' && (y.a === 'allow' || y.b === 'allow') && y.a !== 'explicit-deny' && y.b !== 'explicit-deny'); console.log(`allowed in one direction yet denied: ${oneDirectionWasEnough} requests — all of them cross the account boundary`);
8 subjects x 5 actions x 21 resources = 840 requests identity-based documents 11, resource-based documents 6 same account 285, crossing the account boundary 555 both directions compared same account account boundary allowed result identity-only allow 29 45 29 resource-only allow 6 18 6 both directions allow 57 39 96 neither direction allows 193 453 0 either direction denies 17 32 0 result: allow 131, explicit deny 49, implicit deny 660 allowed in one direction yet denied: 63 requests — all of them cross the account boundary
A hundred thirty-one of eight hundred forty requests are allowed. The first two rows show why same-account and account-boundary are separate columns: within the same account, twenty-nine requests are allowed in only the identity direction, and all twenty-nine end up allowed; the same holds for the six requests allowed in only the resource direction. Across the account boundary the same rows are forty-five and eighteen, but the allowed result is zero.
The last line reduces this to a single number: sixty-three requests are denied even though one direction explicitly allows them. Someone really did write a document, the document matches, but access does not happen because the opposite direction was never written. That is what the account boundary does — it stops one side writing a document from being enough and makes both sides agree mandatory.
Account Boundary
// run2.mjs — account boundary: when a resource-based document becomes mandatory import { REQUEST, RESOURCE_DOCUMENT, IDENTITY_DOCUMENT, TYPE_ACCOUNT, SUBJECT_ACCOUNT, SUBJECT, pattern, identityDocuments, direction, evaluate, inTrace, TRACE } from './direction.mjs'; const crossAccount = REQUEST.filter((i) => SUBJECT_ACCOUNT[i.subject] !== i.resource.account); console.log('resource type account allowing docs boundary requests allow id-allow, denied'); for (const t of Object.keys(TYPE_ACCOUNT)) { const d = crossAccount.filter((i) => i.resource.type === t); const docs = RESOURCE_DOCUMENT.filter((b) => b.effect === 'allow' && b.resource.some((p) => pattern(p, `${t}/production`))).length; const allowed = d.filter((i) => evaluate(i) === 'allow').length; const blocked = d.filter((i) => evaluate(i) !== 'allow' && direction(identityDocuments(i), i) === 'allow').length; console.log(`${t.padEnd(20)} ${TYPE_ACCOUNT[t].padEnd(22)} ${String(docs).padEnd(17)} ${String(d.length).padEnd(18)} ` + `${String(allowed).padEnd(6)} ${blocked}`); } const intersect = (kb, rb) => REQUEST.filter((i) => evaluate(i, kb, rb) === 'allow'); const legitimate = (l) => l.filter(inTrace).length; const full = intersect(IDENTITY_DOCUMENT, RESOURCE_DOCUMENT); console.log(`\nallowed requests ${full.length}; seen in the trace ${legitimate(full)}, unused permission ${full.length - legitimate(full)}`); console.log(`the trace carries ${TRACE.size} (subject, action, type) triples\n`); // Three stages of narrowing the resource-based document const r2Narrow = RESOURCE_DOCUMENT.map((b) => (b.name === 'r2' ? { ...b, subject: SUBJECT.filter((s) => [...TRACE].some((k) => k.startsWith(`${s}|`) && k.endsWith('|object-store'))) } : b)); console.log('resource-based document set allow seen in trace stopped legitimate work'); for (const [name, rb] of [['unchanged (r2 subject *)', RESOURCE_DOCUMENT], ['r2 subject list from trace', r2Narrow], ['r2 removed', RESOURCE_DOCUMENT.filter((b) => b.name !== 'r2')], ['none at all', []]]) { const k = intersect(IDENTITY_DOCUMENT, rb); console.log(`${name.padEnd(34)} ${String(k.length).padEnd(6)} ${String(legitimate(k)).padEnd(15)} ${legitimate(full) - legitimate(k)}`); }
resource type account allowing docs boundary requests allow id-allow, denied measurement-store account-measurement 1 75 12 12 record-stream account-measurement 1 75 0 12 edge-node-group account-measurement 1 75 9 3 object-store account-analytics 1 90 18 0 function-package account-analytics 0 90 0 6 machine-cluster account-shared 1 75 0 6 network-config account-shared 1 75 0 6 allowed requests 131; seen in the trace 75, unused permission 56 the trace carries 32 (subject, action, type) triples resource-based document set allow seen in trace stopped legitimate work unchanged (r2 subject *) 131 75 0 r2 subject list from trace 119 75 0 r2 removed 107 69 6 none at all 86 60 15
The function-package row shows the rule in its plainest form: no allowing resource document is
attached to this type, so all ninety requests that cross the account boundary are denied, and
six of them hold permission written on the identity side. Those six permissions sit somewhere,
get maintained, show up in an audit, and do nothing. The record-stream row has an allowing
document, but because the two subjects it names are already in the same account, it does nothing
for any of the seventy-five requests that cross the boundary.
The lower table gives the stages of narrowing. Once document r2‘s * subject list is replaced
with the subjects that appear in the thirty-day trace, allowed requests drop from a hundred
thirty-one to a hundred nineteen and stopped legitimate work is zero: all twelve permissions were
unused permission. Removing r2 entirely drops allow to a hundred seven and stops six legitimate
jobs; removing every resource-based document drops allow to eighty-six and stopped legitimate
work rises to fifteen. Narrowing’s first stage is free, the second is not.
Why It Was Denied
// run3.mjs — how many documents finding out why an access was denied requires import { REQUEST, SUBJECT_ACCOUNT, identityDocuments, resourceDocuments, direction, evaluate } from './direction.mjs'; const reason = (i) => { const a = direction(identityDocuments(i), i), b = direction(resourceDocuments(i), i); if (a === 'explicit-deny') return 'explicit deny in identity direction'; if (b === 'explicit-deny') return 'explicit deny in resource direction'; if (a === 'allow' || b === 'allow') return 'account boundary: one direction allows'; return 'neither direction allows'; }; const denied = REQUEST.filter((i) => evaluate(i) !== 'allow'); const docsToCheck = (i) => identityDocuments(i).length + resourceDocuments(i).length + 1; // +1: the account rule const avg = (l, f) => (l.reduce((a, x) => a + f(x), 0) / l.length).toFixed(1); console.log(`denied ${denied.length} requests; docs to check = subject-bound + resource-matching + account rule\n`); console.log('denial reason requests avg docs max'); for (const n of ['explicit deny in identity direction', 'explicit deny in resource direction', 'account boundary: one direction allows', 'neither direction allows']) { const l = denied.filter((i) => reason(i) === n); console.log(`${n.padEnd(41)} ${String(l.length).padEnd(9)} ${String(avg(l, docsToCheck)).padEnd(15)} ` + `${Math.max(...l.map(docsToCheck))}`); } console.log(`${'total'.padEnd(41)} ${String(denied.length).padEnd(9)} ${String(avg(denied, docsToCheck)).padEnd(15)} ` + `${Math.max(...denied.map(docsToCheck))}`); console.log(`if only one direction were read (identity only): avg ${avg(denied, (i) => identityDocuments(i).length)} docs`); // How many requests a reader that only looks at one direction answers wrong const identityOnly = (i) => (direction(identityDocuments(i), i) === 'allow' ? 'allow' : 'deny'); const actual = (i) => (evaluate(i) === 'allow' ? 'allow' : 'deny'); const wrong = REQUEST.filter((i) => identityOnly(i) !== actual(i)); console.log(`\na reader that only looks at the identity direction is wrong on ${wrong.length} of ${REQUEST.length} requests`); console.log(`${wrong.filter((i) => SUBJECT_ACCOUNT[i.subject] !== i.resource.account).length} of them cross the account boundary`); const range = (f) => { const v = REQUEST.map(f); return `${Math.min(...v)}-${Math.max(...v)}`; }; console.log(`subject-bound docs ${range((i) => identityDocuments(i).length)}, ` + `resource-matching docs ${range((i) => resourceDocuments(i).length)}`);
denied 709 requests; docs to check = subject-bound + resource-matching + account rule denial reason requests avg docs max explicit deny in identity direction 28 4.4 5 explicit deny in resource direction 21 3.9 4 account boundary: one direction allows 63 3.5 5 neither direction allows 597 3.5 5 total 709 3.5 5 if only one direction were read (identity only): avg 1.4 docs a reader that only looks at the identity direction is wrong on 51 of 840 requests 45 of them cross the account boundary subject-bound docs 1-2, resource-matching docs 0-2
Finding the reason for a denial takes an average of three and a half documents to read, five in the worst case. The number is small because the model is small; the real information is in the ratio. If only one direction were read, the average would be one point four documents: adding the second direction increases the documents to read by two and a half times, and that increase is paid on every denial query.
The payoff for that cost is in the last two lines. A reader that only looks at the identity direction gives the wrong answer on fifty-one of eight hundred forty requests — in every case it says “allowed,” when the request is actually denied. Forty-five of them are caused by the account boundary, the remaining six have an explicit deny in the resource direction. The document was read correctly; what is wrong is looking at only one document.
Summary
- 131 of 840 requests are allowed; 285 requests are within the same account, 555 cross the account boundary.
- 63 requests are denied even though one direction explicitly allows them, and all of them cross the account boundary; within the same account this number is zero, because one direction is enough there.
- The
function-packagetype has no allowing resource document: all 90 requests that cross the boundary are denied, and 6 identity-based permissions sit unused, doing nothing. - 75 of the 131 allowed requests appear in the trace; writing
r2’s*subject list from the trace closes 12 unused permissions and stops no legitimate work. - The second stage is not free: removing
r2stops 6 legitimate jobs, removing every resource-based document stops 15. - Finding the reason for a denial takes an average of 3.5 documents, 1.4 if only one direction were read; a reader that only looks at the identity direction is wrong on 51 of 840 requests.
Next Step
This lesson’s eight subjects were persistent identities, and each one had a long-lived credential. That was the surface counted in the first lesson: twenty-nine storage locations and a key eight hundred eighty days old. However narrowly a permission connection is written, once the key leaks every document works in the leaker’s favor. The next lesson separates access from persistent identity: a subject temporarily assumes another identity. What gets measured is the leak window, the length of the assumption chain, how the trust relationship determines who can assume it, and how many steps it takes before the true subject disappears from the audit record.
To keep your progress and take notes, Log in
My notes
Log in to take notes.