---
title: 'Identity and Resource-Based Policies'
source: 'https://academia.sh/en/courses/cloud-governance/identity-and-resource-based-policies'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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

```js
// 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

```js
// 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

```js
// 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

```js
// 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.
