Skip to content
academia.sh

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-package type 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 r2 stops 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close