@bedrock/resource-restriction
Version:
Bedrock Resource Restriction
466 lines (420 loc) • 20.1 kB
JavaScript
/*!
* Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as database from '@bedrock/mongodb';
import assert from 'assert-plus';
import {matchRequest} from './restrictions.js';
import {ResourceTokenizer} from './ResourceTokenizer.js';
import {tokenizers} from '@bedrock/tokenizer';
bedrock.events.on('bedrock-mongodb.ready', async () => {
await database.openCollections([
'resource-restriction-acquisition'
]);
await database.createIndexes([{
// acquisitions are sharded by acquirer ID, which must be unique
collection: 'resource-restriction-acquisition',
fields: {'acquisition.acquirerId': 1},
options: {unique: true}
}, {
// automatically expire acquisitions with an `expires` date field
collection: 'resource-restriction-acquisition',
fields: {'acquisition.expires': 1},
options: {
unique: false,
expireAfterSeconds: 0
}
}]);
});
/**
* Checks if the acquirer identified by `acquirerId` is authorized to acquire
* the resources specified by the given `request`. The acquisition of
* resources is atomic; either the entire request can be fulfilled or none
* of it can be. Whether or not the request is authorized will be determined
* by applying a set of restrictions according to the given `request` and
* `zones`.
*
* @param {object} options - Options to use.
* @param {string} options.acquirerId - The ID of the acquirer.
* @param {Array} options.request - An array of objects, each with a `resource`,
* `count`, and `requested` millisecond timestamp specifying the resource
* identifier, number of that particular resource to acquire, and the time
* at which the request for the particular resource was made (which may be
* different for every resource), respectively.
* @param {number} options.acquisitionTtl - The default time, in milliseconds,
* for a resource to be considered acquired (held) before being
* automatically released. If any applied restriction does not provide a TTL
* for how long the acquisition must be tracked, then this value will be
* used instead. All acquired resources will be tracked for the maximum TTL
* indicated by applied restrictions (or this default).
* @param {Array} options.zones - A list of zone IDs that are applicable to
* the acquisition and that will be used to determine which restrictions
* apply to the request.
* @param {number} [options.now=Date.now()] - The current system time to use
* in milliseconds.
*
* @returns {object} An object with `authorized` as a boolean indicating
* whether the request is authorized (can be fulfilled); if the request
* cannot be fulfilled, the object also contains `excessResources` expressing
* the number of resources that caused an overage; if any resources in the
* request are not tracked by any restrictions they are reported as
* `untrackedResources` regardless of the value of `authorized`.
*/
export async function check(
{acquirerId, request, acquisitionTtl, zones, now = Date.now()} = {}) {
assert.string(acquirerId, 'acquirerId');
// 1. Get the acquisition record associated with `acquirerId`.
const acquisitionRecord = await _getAcquisitionRecord({acquirerId});
// 2. Create tokenizer for resource IDs.
const resourceTokenizer = new ResourceTokenizer({acquirerId, request});
await resourceTokenizer.process({acquisitionRecord, now});
// 3. Run internal check helper to see if acquisition is possible.
const checkResults = await _check(
{acquirerId, request, zones, resourceTokenizer, acquisitionTtl, now});
// 4. Return only `authorized`, `excessResources`, and `untrackedResources`
const {authorized, excessResources, untrackedResources} = checkResults;
return {authorized, excessResources, untrackedResources};
}
/**
* Performs the same function as `check` but marks resources as acquired if
* their acquisition is authorized. See `check` for more details.
*
* @param {object} options - Options to use.
* @param {string} options.acquirerId - The ID of the acquirer.
* @param {Array} options.request - An array of objects, each with a `resource`,
* `count`, and `requested` millisecond timestamp specifying the resource
* identifier, number of that particular resource to acquire, and the time
* at which the request for the particular resource was made (which may be
* different for every resource), respectively.
* @param {number} options.acquisitionTtl - The default time, in milliseconds,
* for a resource to be considered acquired (held) before being
* automatically released. If any applied restriction does not provide a TTL
* for how long the acquisition must be tracked, then this value will be
* used instead. All acquired resources will be tracked for the maximum TTL
* indicated by applied restrictions (or this default).
* @param {Array} options.zones - A list of zone IDs that are applicable to
* the acquisition and that will be used to determine which restrictions
* apply to the request.
* @param {boolean} [options.forceAcquisition=false] - Forcibly marks the
* resources as acquired even if the request is not authorized.
* @param {number} [options.now=Date.now()] - The current system time to use
* in milliseconds.
*
* @returns {object} An object with `authorized` as a boolean indicating
* whether the request is authorized (can be fulfilled); the object also
* contains `excessResources` expressing the number of resources that caused
* an overage (which may be empty); if any resources in the request are not
* tracked by any restrictions they are reported as `untrackedResources`.
*/
export async function acquire({
acquirerId, request, acquisitionTtl, zones, forceAcquisition = false,
now = Date.now()
} = {}) {
assert.string(acquirerId, 'acquirerId');
/* Keep attempting to authorize acquisition and mark resources as
acquired until success or check fails. This pattern handles the
potential for concurrent operations that may alter whether a
check passes after the check has been run but before the resources
are marked as acquired. */
// 1. Get the acquisition record associated with `acquirerId`.
let acquisitionRecord = await _getAcquisitionRecord({acquirerId});
// 2. Create tokenizer for resource IDs.
const resourceTokenizer = new ResourceTokenizer({acquirerId, request});
await resourceTokenizer.process({acquisitionRecord, now});
// TODO: implement timeout
while(true) {
// 3. Run internal check helper to see if acquisition is possible.
const checkResults = await _check(
{acquirerId, request, zones, resourceTokenizer, acquisitionTtl, now});
const {authorized, excessResources, untrackedResources} = checkResults;
// 4. If authorization failed, return relevant results -- unless force
// acquisition flag is set.
if(!authorized && !forceAcquisition) {
return {authorized, excessResources, untrackedResources};
}
// 5. If nothing was tracked, there is nothing to record, return results.
// Note: Expired acquired resources will not be pruned at this time.
if(checkResults.trackedResources.size === 0) {
return {authorized, excessResources, untrackedResources};
}
// 6. Authorization passed, now attempt to mark resources as acquired
// noting that a concurrent acquistion may cause recording to fail
// and then a loop to check again will be required.
if(await _record(
{acquirerId, acquisitionRecord, resourceTokenizer, checkResults, now})) {
// recording successful, return relevant results
return {authorized, excessResources, untrackedResources};
}
// 7. Get the acquisition record associated with `acquirerId` again
// as a concurrent process has interfered in the resource acquisition.
acquisitionRecord = await _getAcquisitionRecord({acquirerId});
// 8. Process the new acquisition record as acquisitions may have changed
// and the current tokenizer may have been rotated.
await resourceTokenizer.process({acquisitionRecord, now});
}
}
/**
* Releases previously acquired resources up to the counts specified. If
* more resources are requested to be released than are available, then
* the return value will include `excessResources` reporting the overage.
*
* @param {object} options - Options to use.
* @param {string} options.acquirerId - The ID of the acquirer.
* @param {Array} options.request - An array of objects, each with a
* `resource`, `count`, and optional `latest` options, specifying the
* resource identifier, number of that particular resource to release,
* and whether the earliest acquired (default) or latest acquired
* resources should be released, respectively.
* @param {number} [options.now=Date.now()] - The current system time to use
* in milliseconds.
*
* @returns {object} An object with `authorized` set to `true` and `expires`
* set to the earliest date that all remaining acquired resources expire, and
* `excessResources` expressing the number of resources that could not be
* released because they had not been acquired, which may be empty.
*/
export async function release({acquirerId, request, now = Date.now()} = {}) {
assert.string(acquirerId, 'acquirerId');
/* Keep attempting to release resources until they are released atomically.
This pattern handles the potential for concurrent operations that may alter
the results. */
// 1. Get the acquisition record associated with `acquirerId`.
let acquisitionRecord = await _getAcquisitionRecord({acquirerId});
// 2. Create tokenizer for resource IDs.
const resourceTokenizer = new ResourceTokenizer({acquirerId, request});
await resourceTokenizer.process({acquisitionRecord, now});
// TODO: implement timeout
while(true) {
// 3. Build new `tokenized` entry for acquisition record from the request.
const {newTokenized, excessResources, expires, ttl} =
resourceTokenizer.applyReleaseRequest();
// 4. If `newTokenized` has no acquired resources left, remove the
// acquisition record and return on success.
if(newTokenized.length === 1 &&
Object.keys(newTokenized[0].resources).length === 0) {
// remove acquisition record and return on success, otherwise proceed
// to loop below and try again
if(await _removeAcquisitionRecord({acquirerId, acquisitionRecord})) {
return {authorized: true, excessResources, expires};
}
} else {
// 5. Else, if there was a change that should be recorded, record it
// and return on success; do not upsert a record if none exists.
if(await _updateAcquisitionRecord({
acquirerId, acquisitionRecord, newTokenized, expires, ttl,
upsert: false, now
})) {
// recording successful, return relevant results
return {authorized: true, excessResources, expires};
}
}
// 6. Get the acquisition record associated with `acquirerId` again
// as a concurrent process has interfered in the resource release.
acquisitionRecord = await _getAcquisitionRecord({acquirerId});
// 7. Process the new acquisition record as acquisitions may have changed
// and the current tokenizer may have been rotated.
await resourceTokenizer.process({acquisitionRecord, now});
}
}
async function _check({
acquirerId, request, zones, resourceTokenizer, acquisitionTtl, now
} = {}) {
// get already acquired resources that match `request`
const acquired = await resourceTokenizer.getUntokenizedAcquisitionMap();
// enable restriction to ask for any other acquired resources that are
// known to it but not present in the request itself (useful for at least
// geographical restrictions)
const getAcquisitionMap = resourceTokenizer
.getUntokenizedAcquisitionMap.bind(resourceTokenizer);
// get applicable restrictions
const {restrictions} = await matchRequest({request, zones});
// aggregate excess and untracked resources
let authorized = true;
const excessResources = new Map();
const trackedResources = new Set();
const resources = request.map(e => e.resource);
let maxRestrictionTtl = 0;
for(const restriction of restrictions) {
const result = await restriction.apply(
{acquirerId, acquired, request, zones, now, getAcquisitionMap});
// all restrictions must be authorized or none are not authorized
authorized = authorized && result.authorized;
// get resources tracked by the restriction, defaulting to the specific
// resource that triggered the restriction
const restrictionTrackedResources = result.trackedResources ||
[restriction.restriction.resource];
// add tracked resources
restrictionTrackedResources.forEach(trackedResources.add, trackedResources);
if(!result.authorized) {
// record maximum excess count across all restricted resources
for(const resource of restrictionTrackedResources) {
const count = Math.max(
excessResources.get(resource) || 0, result.excess);
excessResources.set(resource, count);
}
}
// update max restriction TTL
const {ttl = acquisitionTtl} = result;
maxRestrictionTtl = Math.max(maxRestrictionTtl, ttl);
}
// subtract tracked resources to get untracked resources
const untrackedResources = resources.filter(r => !trackedResources.has(r));
// output results
return {
authorized,
excessResources: [...excessResources.entries()].map(
([resource, count]) => ({resource, count})),
untrackedResources,
trackedResources,
maxRestrictionTtl
};
}
async function _record(
{acquirerId, acquisitionRecord, resourceTokenizer, checkResults, now} = {}) {
// convert `acquisitionRecord` into a mongodb upsert query that depends on
// the previous resource numbers being unchanged (or the record not existing)
// in order to detect conflicting concurrent updates
// build new `tokenized` entry for acquisition record from request
const {newTokenized, expires, ttl} = resourceTokenizer.applyAcquireRequest(
{checkResults});
// if `newTokenized` has no acquired resources left, remove the
// acquisition record entirely
if(newTokenized.length === 1 &&
Object.keys(newTokenized[0].resources).length === 0) {
return _removeAcquisitionRecord({acquirerId, acquisitionRecord});
}
// otherwise there are acquisitions to track so record them
return _updateAcquisitionRecord({
acquirerId, acquisitionRecord, newTokenized, expires, ttl, upsert: true,
now
});
}
export async function _getAcquisitionRecord({
acquirerId, explain = false
} = {}) {
const query = {'acquisition.acquirerId': acquirerId};
const projection = {_id: 0};
const collection = database.collections['resource-restriction-acquisition'];
if(explain) {
// 'find().limit(1)' is used here because 'findOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query, {projection}).limit(1);
return cursor.explain('executionStats');
}
let record = await collection.findOne(query, {projection});
if(!record) {
// creating a default record if none exists
const {id: tokenizerId} = await tokenizers.getCurrent();
record = {
// no `meta` set for a default record, used to determine if the record
// is a totally new record
acquisition: {
acquirerId,
tokenized: [{
tokenizerId,
resources: {}
}],
// since this is the default record, it should expire immediately if
// nothing gets added to it; defaulting `expires` to any other time
// would be arbitrary and could cause it to unnecessarily persist
expires: new Date(),
ttl: 0
}
};
}
return record;
}
export async function _updateAcquisitionRecord({
acquirerId, acquisitionRecord, newTokenized, expires, ttl, upsert,
explain = false, now = Date.now()
} = {}) {
// Note: If `upsert=false` and `acquisitionRecord` is new, we should not
// hit this code path because we're dealing with a release where there
// would be nothing acquired; it should hit the `_removeAcquisitionRecord`
// code path instead and return early without having to hit the database.
// TODO: optimize to more selectively edit `tokenized` entry vs. full replace
// build a query that requires the old `tokenized` values to be unchanged
// in order to apply an update (to ensure a concurrent change didn't
// intervene)
const {acquisition: {sequence}} = acquisitionRecord;
const query = {
'acquisition.acquirerId': acquirerId,
'acquisition.sequence': sequence ?? {$exists: false}
};
const $set = {
'meta.updated': now,
'acquisition.sequence': sequence === undefined ? 0 : sequence + 1,
'acquisition.tokenized': newTokenized,
// store `expires` as a Date instance to enable mongo's TTL index
'acquisition.expires': new Date(expires),
'acquisition.ttl': ttl
};
const update = {$set};
const dbOptions = {};
if(upsert) {
dbOptions.upsert = true;
update.$setOnInsert = {
'meta.created': now,
'acquisition.acquirerId': acquirerId
};
}
const collection = database.collections['resource-restriction-acquisition'];
if(explain) {
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query).limit(1);
return cursor.explain('executionStats');
}
try {
const result = await collection.updateOne(query, update, dbOptions);
// return `true` if the record was updated
if(result.modifiedCount > 0 || result.upsertedCount > 0) {
return true;
}
} catch(e) {
if(!database.isDuplicateError(e)) {
throw e;
}
// a duplicate error happens when the query does not match and `upsert`
// is true; a query should only fail to match when a concurrent process
// updated the record -- so we ignore duplicate errors here to treat them
// the same as when a query does not match and `upsert` is false
}
// no change was recorded which means the query did not match or the
// update itself would have caused no change to the existing record; since
// the update itself includes at least changing `sequence`, we assume
// that the query did not match and return `false` indicating that the
// update failed due to a concurrent change and that it needs to be re-run;
// note that the query may not have matched because:
// 1. the record expired and was removed by mongo's expiration service
// 2. the record was removed due to a release/expiration of acquisitions
// 3. the record's current resource acquisitions changed (release or acquire)
return false;
}
export async function _removeAcquisitionRecord({
acquirerId, acquisitionRecord, explain = false
}) {
// existing `acquisitionRecord` is new if it has no `meta`, so there is
// nothing to delete, optimize away making the call
if(!acquisitionRecord.meta) {
return true;
}
// TODO: optimize to more selectively edit `tokenized` entry vs. full replace
// build a query that requires the old `tokenized` values to be unchanged
// in order to apply a delete (to ensure a concurrent change didn't
// intervene)
const query = {
'acquisition.acquirerId': acquirerId,
'acquisition.tokenized': acquisitionRecord.acquisition.tokenized
};
const collection = database.collections['resource-restriction-acquisition'];
if(explain) {
// 'find().limit(1)' is used here because 'deleteOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query).limit(1);
return cursor.explain('executionStats');
}
const result = await collection.deleteOne(query);
// return `true` if something changed
return result.deletedCount > 0;
}