Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions packages/capability-ledger/src/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,17 +113,37 @@ export class CapabilityLedger {

/**
* Enable a capability.
*
* `policyDecisionRef` is REQUIRED. "enabled" is the only state that authorises use,
* so recording it without naming the decision that authorised it produces a ledger
* entry asserting an adjudication that may never have occurred — and, being
* indistinguishable from a properly adjudicated one, it cannot be audited apart from
* it afterwards. Every other state describes an observation; only this one grants.
*
* @param {string} capabilityId
* @param {CapabilityOwner} owner
* @param {string|null} policyDecisionRef
* @param {string} policyDecisionRef reference to the decision that authorised this
* @param {string[]} [evidenceRefs]
*/
enable(capabilityId, owner, policyDecisionRef = null, evidenceRefs = []) {
enable(capabilityId, owner, policyDecisionRef, evidenceRefs = []) {
if (typeof policyDecisionRef !== 'string' || policyDecisionRef.trim() === '') {
throw new Error(
`capability "${capabilityId}" cannot be enabled without a policyDecisionRef: ` +
'enabling is the only state that authorises use, and an unattributed grant is ' +
'indistinguishable from an adjudicated one',
);
}
return this._emit(capabilityId, 'enabled', owner, { policyDecisionRef, evidenceRefs });
}

/**
* Deny a capability via policy.
*
* `policyDecisionRef` remains optional here, deliberately and asymmetrically: a
* refusal that cannot cite its policy is still a refusal and still fails closed,
* whereas a grant that cannot cite its policy is an unattributed authorisation.
* The two mistakes do not cost the same, so they are not gated the same.
*
* @param {string} capabilityId
* @param {CapabilityOwner} owner
* @param {string|null} policyDecisionRef
Expand DownExpand Up@@ -246,10 +266,45 @@ export class CapabilityLedger {
/**
* Returns true only when the ledger reports the capability as "enabled".
* Feature use must be gated on this before proceeding.
*
* Note this returns a value a caller may ignore, and every caller that forgets it
* fails OPEN. Prefer {@link assertEnabled} where the call site can throw.
*
* @param {string} capabilityId
* @returns {boolean}
*/
isEnabled(capabilityId) {
return this.getState(capabilityId) === 'enabled';
}

/**
* Fail-closed gate: throw unless the capability is enabled.
*
* `isEnabled` states the requirement in a docstring — "feature use must be gated on
* this" — and returns a boolean, so a caller that never asks proceeds exactly as if
* permission had been granted. A requirement whose only enforcement is a sentence in
* a comment is enforced by whoever happened to read the comment.
*
* The thrown error names the state actually recorded, and the policy decision behind
* it when there is one, so a refusal is diagnosable rather than merely opaque.
*
* @param {string} capabilityId
* @returns {import('./schema.js').CapabilityReceipt} the receipt that authorised use
* @throws {Error} when the capability is not enabled
*/
assertEnabled(capabilityId) {
const state = this.getState(capabilityId);
if (state === 'enabled') return this.getReceipt(capabilityId);

const receipt = this.getReceipt(capabilityId);
const because = receipt?.policyDecisionRef
? ` (policy decision: ${receipt.policyDecisionRef})`
: '';
throw new Error(
// getState() reports an unknown capability as null, not undefined.
state == null
? `capability "${capabilityId}" is not in the ledger; refusing use of an undeclared capability`
: `capability "${capabilityId}" is "${state}", not "enabled"${because}; refusing use`,
);
}
}
92 changes: 85 additions & 7 deletions packages/capability-ledger/tests/ledger.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,7 +183,7 @@ describe('failed reconciliation', () => {

test('reconcile reports failed capability as pending', () => {
const ledger = new CapabilityLedger();
ledger.enable('cap-a', 'runtime');
ledger.enable('cap-a', 'runtime', 'policy:test-grant:cap-a');
ledger.fail('cap-b', 'runtime', []);

const { enabled, pending } = ledger.reconcile();
Expand All@@ -193,8 +193,8 @@ describe('failed reconciliation', () => {

test('reconcile returns all enabled capabilities', () => {
const ledger = new CapabilityLedger();
ledger.enable('cap-a', 'runtime');
ledger.enable('cap-b', 'runtime');
ledger.enable('cap-a', 'runtime', 'policy:test-grant:cap-a');
ledger.enable('cap-b', 'runtime', 'policy:test-grant:cap-b');

const { enabled, pending } = ledger.reconcile();
assert.deepEqual(enabled.sort(), ['cap-a', 'cap-b']);
Expand DownExpand Up@@ -228,7 +228,7 @@ describe('conflict warnings', () => {
const ledger = new CapabilityLedger();
ledger.declare('cap-x', 'runtime');
ledger.logConflict('cap-x', 'warning 1');
ledger.enable('cap-x', 'runtime');
ledger.enable('cap-x', 'runtime', 'policy:test-grant:cap-x');

const receipt = ledger.getReceipt('cap-x');
assert.equal(receipt.state, 'enabled');
Expand All@@ -237,7 +237,7 @@ describe('conflict warnings', () => {

test('reconcile includes conflicted capabilities', () => {
const ledger = new CapabilityLedger();
ledger.enable('cap-conflict', 'runtime');
ledger.enable('cap-conflict', 'runtime', 'policy:test-grant:cap-conflict');
ledger.logConflict('cap-conflict', 'server disagrees');

const { conflicted } = ledger.reconcile();
Expand All@@ -250,7 +250,7 @@ describe('conflict warnings', () => {
describe('getAll', () => {
test('returns all tracked receipts', () => {
const ledger = new CapabilityLedger();
ledger.enable('a', 'runtime');
ledger.enable('a', 'runtime', 'policy:test-grant:a');
ledger.deny('b', 'policy', null, []);

const all = ledger.getAll();
Expand All@@ -265,7 +265,7 @@ describe('getAll', () => {
describe('receipt timestamp', () => {
test('timestamp is a valid ISO-8601 date string', () => {
const ledger = new CapabilityLedger();
ledger.enable('ts-test', 'runtime');
ledger.enable('ts-test', 'runtime', 'policy:test-grant:ts-test');
const receipt = ledger.getReceipt('ts-test');
const parsed = new Date(receipt.timestamp);
assert.ok(!isNaN(parsed.getTime()), 'timestamp is not a valid date');
Expand DownExpand Up@@ -322,3 +322,81 @@ describe('full lifecycle', () => {
assert.equal(ledger.isEnabled('degrade-test'), false);
});
});

describe('a grant must name the decision that authorised it', () => {
// "enabled" is the only state that authorises use. Recorded without a
// policyDecisionRef it asserts an adjudication that may never have happened, and
// is then indistinguishable from a properly adjudicated grant when audited.
test('enable() refuses without a policyDecisionRef', () => {
const ledger = new CapabilityLedger();
assert.throws(() => ledger.enable('pdf-viewer', 'runtime'), /without a policyDecisionRef/);
assert.equal(ledger.getState('pdf-viewer'), null, 'a refused grant must leave no trace of enablement');
assert.equal(ledger.isEnabled('pdf-viewer'), false);
});

test('enable() refuses an empty or whitespace policyDecisionRef', () => {
const ledger = new CapabilityLedger();
assert.throws(() => ledger.enable('a', 'runtime', ''), /without a policyDecisionRef/);
assert.throws(() => ledger.enable('a', 'runtime', ' '), /without a policyDecisionRef/);
assert.throws(() => ledger.enable('a', 'runtime', null), /without a policyDecisionRef/);
assert.equal(ledger.isEnabled('a'), false);
});

test('enable() proceeds when the decision is named', () => {
const ledger = new CapabilityLedger();
ledger.enable('pdf-viewer', 'runtime', 'policy:allow-pdf:v1', ['config:pdf:on']);
assert.equal(ledger.isEnabled('pdf-viewer'), true);
assert.equal(ledger.getReceipt('pdf-viewer').policyDecisionRef, 'policy:allow-pdf:v1');
});

test('deny() stays permissive without a policy ref — the asymmetry is deliberate', () => {
// A refusal that cannot cite its policy still fails closed. A grant that cannot
// cite its policy is an unattributed authorisation. Different costs, different gates.
const ledger = new CapabilityLedger();
ledger.deny('restricted', 'policy');
assert.equal(ledger.getState('restricted'), 'blocked_by_policy');
assert.equal(ledger.isEnabled('restricted'), false);
});
});

describe('assertEnabled fails closed where isEnabled fails open', () => {
test('returns the authorising receipt when enabled', () => {
const ledger = new CapabilityLedger();
ledger.enable('pdf-viewer', 'runtime', 'policy:allow-pdf:v1');
const receipt = ledger.assertEnabled('pdf-viewer');
assert.equal(receipt.state, 'enabled');
assert.equal(receipt.policyDecisionRef, 'policy:allow-pdf:v1');
});

test('throws on a denied capability, naming the state and the policy behind it', () => {
const ledger = new CapabilityLedger();
ledger.deny('restricted', 'policy', 'policy:deny-restricted:v2');
assert.throws(() => ledger.assertEnabled('restricted'), /blocked_by_policy/);
assert.throws(() => ledger.assertEnabled('restricted'), /policy:deny-restricted:v2/);
});

test('throws on an undeclared capability rather than treating absence as permission', () => {
const ledger = new CapabilityLedger();
assert.throws(() => ledger.assertEnabled('never-declared'), /not in the ledger/);
});

test('throws on every non-enabled state', () => {
// The negative control that matters: a capability part-way through its lifecycle
// is not usable, and each intermediate state must refuse rather than pass through.
for (const [id, drive] of [
['declared', (l) => l.declare('c', 'runtime')],
['requested', (l) => l.request('c', 'UI')],
['negotiating', (l) => l.negotiate('c', 'runtime')],
['available', (l) => l.setAvailable('c', 'server')],
['degraded', (l) => l.degrade('c', 'runtime')],
['unsupported_by_runtime', (l) => l.setUnsupportedByRuntime('c', 'runtime')],
['missing_plugin', (l) => l.setMissingPlugin('c', 'plugin')],
['failed', (l) => l.fail('c', 'runtime')],
]) {
const ledger = new CapabilityLedger();
drive(ledger);
assert.equal(ledger.isEnabled('c'), false, `${id} must not read as enabled`);
assert.throws(() => ledger.assertEnabled('c'), new RegExp(id), `${id} must refuse use`);
}
});
});