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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# bedrock-profile-http ChangeLog

## 26.2.1 - 2025-11-dd

### Fixed
- Fix zcap policy schema to allow `maxDelegationTtl`.

## 26.2.0 - 2025-11-18

### Added
Expand Down
39 changes: 25 additions & 14 deletions lib/refreshedZcapCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,23 @@ bedrock.events.on('bedrock.init', async () => {
REFRESHED_ZCAP_CACHE = new LruCache(cfg.caches.refreshedZcap);
});

export async function getRefreshedZcap({profileId, capability}) {
export async function getRefreshedZcap({
profileId, capability, capabilityChain
}) {
const key = _createCacheKey({profileId, capability});
const fn = () => _getUncached({profileId, capability});
const fn = () => _getUncached({profileId, capability, capabilityChain});
return REFRESHED_ZCAP_CACHE.memoize({key, fn});
}

export async function getRefreshZcapPolicy({profileId, delegateId}) {
function _createCacheKey({profileId, capability}) {
const json = JSON.stringify({
profileId, canonicalZcap: canonicalize(capability)
});
const hash = createHash('sha256').update(json, 'utf8').digest('base64url');
return hash;
}

async function _getZcapPolicy({profileId, delegateId}) {
try {
return brZcapStorage.policies.get({
controller: profileId, delegate: delegateId
Expand All @@ -42,18 +52,10 @@ export async function getRefreshZcapPolicy({profileId, delegateId}) {
}
}

function _createCacheKey({profileId, capability}) {
const json = JSON.stringify({
profileId, canonicalZcap: canonicalize(capability)
});
const hash = createHash('sha256').update(json, 'utf8').digest('base64url');
return hash;
}

async function _getUncached({profileId, capability}) {
async function _getUncached({profileId, capability, capabilityChain}) {
// get the policy for the profile + controller (delegate)
const {controller: delegateId} = capability;
const policy = await getRefreshZcapPolicy({profileId, delegateId});
const {policy} = await _getZcapPolicy({profileId, delegateId});

// check policy constraints
const {authorizeZcapInvocationOptions} = bedrock.config['profile-http'];
Expand Down Expand Up @@ -92,10 +94,19 @@ async function _getUncached({profileId, capability}) {

// compute new `expires` from policy, defaulting to max delegation TTL
const now = Date.now();
const expires = new Date(
let expires = new Date(
now + (policy.refresh?.constraints?.maxDelegationTtl ??
authorizeZcapInvocationOptions.maxDelegationTtl));

// ensure policy computed expiry doesn't exceed parent capability
const parentZcap = capabilityChain.at(-2);
if(parentZcap.expires !== undefined) {
const parentExpires = new Date(parentZcap.expires);
if(expires > parentExpires) {
expires = parentExpires;
}
}

return profileAgents.refreshCapability({
capability, profileSigner, now, expires
});
Expand Down
17 changes: 12 additions & 5 deletions lib/zcaps.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import * as bedrock from '@bedrock/core';
import * as brZcapStorage from '@bedrock/zcap-storage';
import * as middleware from './middleware.js';
import * as schemas from '../schemas/bedrock-profile-http.js';
import {getRefreshedZcap, getRefreshZcapPolicy} from './refreshedZcapCache.js';
import {asyncHandler} from '@bedrock/express';
import cors from 'cors';
import {getRefreshedZcap} from './refreshedZcapCache.js';
import {createValidateMiddleware as validate} from '@bedrock/validation';

const {util: {BedrockError}} = bedrock;
Expand Down Expand Up @@ -124,7 +124,9 @@ bedrock.events.on('bedrock-express.configure.routes', app => {
middleware.authorizeProfileZcapRequest(),
asyncHandler(async (req, res) => {
const {profileId, delegateId} = req.params;
const {policy} = await getRefreshZcapPolicy({profileId, delegateId});
const {policy} = await brZcapStorage.policies.get({
controller: profileId, delegate: delegateId
});
res.json({policy});
}));

Expand All @@ -149,8 +151,11 @@ bedrock.events.on('bedrock-express.configure.routes', app => {
middleware.verifyRefreshableZcapDelegation(),
asyncHandler(async (req, res) => {
const {profileId} = req.params;
const {body: capability} = req;
const zcap = await getRefreshedZcap({profileId, capability});
const {body: capability, verifyRefreshableZcapDelegation} = req;
const {capabilityChain} = verifyRefreshableZcapDelegation;
const zcap = await getRefreshedZcap({
profileId, capability, capabilityChain
});
res.json(zcap);
}));

Expand All @@ -161,7 +166,9 @@ bedrock.events.on('bedrock-express.configure.routes', app => {
middleware.authorizeProfileZcapRequest(),
asyncHandler(async (req, res) => {
const {profileId, delegateId} = req.params;
const {policy} = await getRefreshZcapPolicy({profileId, delegateId});
const {policy} = await brZcapStorage.policies.get({
controller: profileId, delegate: delegateId
});
// return only `refresh=false` or `refresh.constraints` to client
const viewablePolicy = {};
const {refresh} = policy;
Expand Down
3 changes: 3 additions & 0 deletions schemas/bedrock-profile-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@ const zcapPolicy = {
type: 'object',
additionalProperties: false,
properties: {
maxDelegationTtl: {
type: 'number'
},
maxTtlBeforeRefresh: {
type: 'number'
}
Expand Down
125 changes: 125 additions & 0 deletions test/mocha/50-zcap-refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,131 @@ describe('zcap refresh', () => {
should.not.exist(result.results[0].error);
should.not.exist(result.results[0].error);

// refresh zcap expiry should be more than 360 days from now
const days360 = Date.now() + 1000 * 60 * 60 * 24 * 360;
result.results.forEach(r => {
const expires = (new Date(r.capability.expires)).getTime();
expires.should.be.gte(days360);
});

// set expected after
expectedAfter = result.refresh.after;

// update record
await mockData.refreshingService.configStorage.update({
config: {...result.config, sequence: result.config.sequence + 1},
refresh: {
enabled: result.refresh.enabled,
after: result.refresh.after
}
});
resolve(mockData.refreshingService.configStorage.get({id: configId}));
} catch(e) {
reject(e);
}
}));

let err;
let result;
let zcaps;
try {
const {id: meterId} = await helpers.createMeter({
controller: profileId, serviceType: 'refreshing'
});
zcaps = await _createZcaps({
profileId, zcapClient, serviceAgent
});
result = await helpers.createConfig({
profileId, zcapClient, meterId, servicePath: '/refreshables',
options: {
id: configId,
zcaps
}
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result);
result.should.have.keys([
'controller', 'id', 'sequence', 'meterId', 'zcaps'
]);
result.sequence.should.equal(0);
result.controller.should.equal(profileId);

// wait for refresh promise to resolve
const record = await configRefreshPromise;
record.config.id.should.equal(configId);
record.config.sequence.should.equal(1);
record.meta.refresh.enabled.should.equal(true);
record.meta.refresh.after.should.equal(expectedAfter);

// ensure zcaps changed
for(const [key, value] of Object.entries(zcaps)) {
record.config.zcaps[key].should.not.deep.equal(value);
}
});
it('should refresh zcaps w/1 day max delegation TTL', async () => {
// remove any existing policy
await zcapClient.request({
url: urls.policy,
capability: rootZcap,
method: 'delete',
action: 'write'
});

// add constrained policy
await zcapClient.write({
url: urls.policies,
capability: rootZcap,
json: {
policy: {
sequence: 0,
controller: profileId,
delegate: serviceAgent.id,
refresh: {
constraints: {
maxDelegationTtl: 1000 * 60 * 60 * 24
}
}
}
}
});

// function to be called when refreshing the created config
let expectedAfter;
const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`;
const configRefreshPromise = new Promise((resolve, reject) =>
mockData.refreshHandlerListeners.set(configId, async ({
record, signal
}) => {
try {
const now = Date.now();
const later = now + 1000 * 60 * 5;
const result = await refreshZcaps({
serviceType: 'refreshing', config: record.config, signal
});
result.refresh.enabled.should.equal(true);
should.exist(result.config);
result.refresh.after.should.be.gte(later);
should.exist(result.results);
result.results.length.should.equal(4);
result.results[0].refreshed.should.equal(true);
result.results[1].refreshed.should.equal(true);
result.results[2].refreshed.should.equal(true);
result.results[3].refreshed.should.equal(true);
should.not.exist(result.results[0].error);
should.not.exist(result.results[0].error);
should.not.exist(result.results[0].error);
should.not.exist(result.results[0].error);

// refresh zcap expiry should be less than 2 days from now
const twoDaysFromNow = Date.now() + 1000 * 60 * 60 * 24 * 2;
result.results.forEach(r => {
const expires = (new Date(r.capability.expires)).getTime();
expires.should.be.lte(twoDaysFromNow);
});

// set expected after
expectedAfter = result.refresh.after;

Expand Down