timeld-gateway
Version:
Timeld Gateway server
365 lines (348 loc) • 12.5 kB
JavaScript
import { propertyValue } from '@m-ld/m-ld';
import { AccountOwnedId, idSet, isDomainEntity, safeRefsIn, Session } from 'timeld-common';
import { Ask, accountHasTimesheet, timesheetHasProject, userIsAdmin } from './statements.mjs';
import ReadPatterns from './ReadPatterns.mjs';
import WritePatterns from './WritePatterns.mjs';
import { each } from 'rx-flowable';
import { validate } from 'jtd';
import {
BadRequestError, ConflictError, ForbiddenError, toHttpError, UnauthorizedError
} from '../rest/errors.mjs';
import ConnectorExtension from './Connector.mjs';
/**
* Javascript representation of an Account subject in the Gateway domain.
* Instances are ephemeral, instantiated dynamically on demand.
* @implements BeforeWriteTriggers
*/
export default class Account {
/**
* @param {Gateway} gateway
* @param {GraphSubject} src
*/
static fromJSON(gateway, src) {
// noinspection JSCheckFunctionSignatures
return new Account(gateway, {
name: src['@id'],
emails: propertyValue(src, 'email', Set, String),
keyids: propertyValue(src, 'keyid', Set, String),
admins: idSet(safeRefsIn(src, 'vf:primaryAccountable')),
timesheets: safeRefsIn(src, 'timesheet'),
projects: safeRefsIn(src, 'project')
});
}
/**
* @param {Gateway} gateway
* @param {string} name plain account name
* @param {Iterable<string>} emails verifiable account identities
* @param {Iterable<string>} keyids per-device keys
* @param {Iterable<string>} admins admin (primary accountable) IRIs
* @param {Reference[]} timesheets timesheet ID Refs
* @param {Reference[]} projects project ID Refs
*/
constructor(gateway, {
name,
emails = [],
keyids = [],
admins = [],
timesheets = [],
projects = []
}) {
this.gateway = gateway;
this.name = name;
this.emails = new Set([...emails ?? []]);
this.keyids = new Set([...keyids ?? []]);
this.admins = new Set([...admins ?? []]);
this.timesheets = timesheets ?? [];
this.projects = projects ?? [];
/**
* Cache of account-owned entities
* @type {{ Project: Set<string>, Timesheet: Set<string> }}
* @see allOwned
*/
this.owned = {}; // See allOwned
}
/**
* Activation of a gateway account requires an initial timesheet.
* (This is because Ably cannot create a key without at least one capability.)
*
* @param {string} email
* @returns {Promise<string>} Authorisation key for the account
*/
async activate(email) {
// Every activation creates a new key (assumes new device)
const keyDetails = await this.gateway.keyStore
.mintKey(`${this.name}@${this.gateway.domainName}`);
// Store the keyid and the email
this.keyids.add(keyDetails.key.keyid);
this.emails.add(email);
await this.gateway.domain.write(this.toJSON());
return keyDetails.key.toString();
}
/**
* @param {string} keyid user key ID
* @param {AccessRequest|undefined} [access] request
* @returns {Promise<AuthKeyDetail>}
* @throws {import('restify-errors').DefinedHttpError}
*/
async authorise(keyid, access) {
if (!this.keyids.has(keyid))
throw new UnauthorizedError(
`Key ${keyid} does not belong to account ${this.name}`);
return new Promise(async (resolve, reject) => {
this.gateway.domain.read(async state => {
try {
// noinspection JSIncompatibleTypesComparison
if (access != null)
await this.checkAccess(state, access);
try {
const keyDetail = await this.gateway.keyStore.pingKey(
keyid, () => this.allOwnedTimesheetIds(state));
return !keyDetail.revoked ? resolve(keyDetail) :
reject(new UnauthorizedError('Key revoked'));
} catch (e) {
// TODO: Assuming this is a Not Found
return reject(new UnauthorizedError(e));
}
} catch (e) {
return reject(toHttpError(e));
}
});
});
}
/**
* @param {MeldReadState} state
* @param {AccessRequest} access request
* @returns {Promise<void>}
*/
async checkAccess(state, access) {
const ask = new Ask(state);
const iri = access.id.toRelativeIri();
const writable = {
'Timesheet': await this.allOwned(state, 'Timesheet'),
'Project': await this.allOwned(state, 'Project')
};
if (access.forWrite && !(await ask.exists({ '@id': iri }))) {
// Creating; check write access to account
if (access.id.account !== this.name &&
!(await ask.exists(userIsAdmin(this.name, access.id.account))))
throw new ForbiddenError();
// Otherwise OK to create
writable[access.forWrite].add(iri);
} else if (!writable['Timesheet'].has(iri) && !writable['Project'].has(iri)) {
if (access.forWrite) {
throw new ForbiddenError();
} else {
// Finally check for a readable timesheet through one of the projects
if (!(await Promise.all([...writable['Project']].map(project =>
ask.exists(timesheetHasProject(iri, project))))).includes(true))
throw new ForbiddenError();
}
}
}
/**
* @param {MeldReadState} state
* @param {'Timesheet'|'Project'} type
* @returns {Promise<Set<string>>}
*/
async allOwned(state, type) {
if (this.owned[type] == null) {
this.owned[type] = idSet(
type === 'Timesheet' ? this.timesheets : this.projects);
await state.read({
'@select': '?owned',
'@where': ({
'@type': 'Account',
'vf:primaryAccountable': { '@id': this.name },
[type.toLowerCase()]: '?owned'
})
}).forEach(result => this.owned[type].add(result['?owned']['@id']));
}
return this.owned[type];
}
/**
* @param {MeldReadState} state
* @returns {Promise<AccountOwnedId[]>}
*/
async allOwnedTimesheetIds(state) {
return [...await this.allOwned(state, 'Timesheet')]
.map(iri => AccountOwnedId.fromIri(iri, this.gateway.domainName));
}
/**
* @param {Read} query
* @returns {Promise<Results>} results
*/
async read(query) {
// Check that the given pattern matches a permitted query
const matchingPattern = new ReadPatterns(this.name).matchPattern(query);
if (matchingPattern == null)
throw new ForbiddenError('Unrecognised read pattern: %j', query);
return new Promise((resolve, reject) => {
this.gateway.domain.read(async state => {
try {
// noinspection JSCheckFunctionSignatures
resolve(state.read(await matchingPattern.check(state, query)).consume);
} catch (e) {
reject(e);
}
});
});
}
/**
* @param {MeldReadState} state the current domain state
* @param {Reference} tsRef the timesheet ref
* @returns {Promise<void>}
*/
beforeInsertTimesheet = async (state, tsRef) => {
if (tsRef != null) {
const tsId = this.gateway.ownedRefAsId(tsRef);
const ask = new Ask(state);
if (await ask.exists(accountHasTimesheet(tsId)))
throw new ConflictError('Timesheet already exists');
if (this.name !== tsId.account &&
!(await ask.exists(userIsAdmin(this.name, tsId.account))))
throw new UnauthorizedError('No access to timesheet');
await this.gateway.initTimesheet(tsId, true);
}
};
/**
* @param {MeldReadState} state
* @param {GraphSubject} src
* @returns {Promise<Subject>}
*/
beforeInsertConnector = async (state, src) => {
try { // Create a temporary connector (the real one will be loaded later)
const ext = await ConnectorExtension.fromJSON(src).initialise(this.gateway);
// Flow matched entries to the extension
await Promise.all(ext.appliesTo.map(id => this.revupConnector(state, ext, id)));
return ext.toJSON();
} catch (e) {
// An error here is almost certainly due to misconfiguration
throw new BadRequestError('Unable to initialise connector', e);
}
};
/**
* @param {MeldReadState} state
* @param {ConnectorExtension} ext
* @param {string} timesheetId
*/
async revupConnector(state, ext, timesheetId) {
const tsId = this.gateway.ownedRefAsId({ '@id': timesheetId });
if (await this.gateway.isGenesisTs(state, tsId))
throw new BadRequestError('Timesheet not found: %s', tsId);
const tsClone = await this.gateway.initTimesheet(tsId, false);
// TODO: This holds locks on both gateway and timesheet state!
// Note this may mutate the extension object
await tsClone.write(state => ext.syncTimesheet(tsId, state));
}
/**
* @param {Query} query
*/
async write(query) {
const matchingPattern =
new WritePatterns(this.name, this).matchPattern(query);
if (matchingPattern == null)
throw new ForbiddenError('Unrecognised write pattern: %j', query);
await this.gateway.domain.write(async state => {
await state.write(await matchingPattern.check(state, query));
});
}
/**
* @param {Results} subjects Domain entity subjects, i.e. Projects, Timesheets & Entries
* @returns {Promise<void>}
*/
async import(subjects) {
const sessions = {};
await each(subjects, async src => {
// Validate schema observance
const validation = validate(isDomainEntity, src);
if (validation.length > 0)
throw new BadRequestError(
'Malformed domain entity %j', validation);
switch (src['@type']) {
case 'Timesheet':
case 'Project':
return this.importOwned(src);
case 'Entry':
return this.importEntry(src, sessions);
default:
throw new BadRequestError(
'Unknown entity type %s for %s', src['@type'], src['@id']);
}
});
}
/**
* @param {GraphSubject} src
* @returns {Promise<void>}
*/
async importOwned(src) {
const id = this.gateway.ownedRefAsId(src);
if (!id.isValid)
throw new BadRequestError(
'Malformed entity identity %s', src['@id']);
await this.gateway.domain.write(async state => {
await this.checkAccess(state, { id, forWrite: src['@type'] });
if (src['@type'] === 'Timesheet' && await this.gateway.isGenesisTs(state, id))
await this.gateway.initTimesheet(id, true);
return state.write({
'@delete': { '@id': src['@id'] },
'@insert': { '@id': id.account, [src['@type'].toLowerCase()]: src }
});
});
}
/**
* @param {GraphSubject} src
* @param {{ [key: string]: Session }} sessions
* @returns {Promise<void>}
*/
async importEntry(src, sessions) {
// Check @id not provided
if (src['@id'])
throw new BadRequestError(
'Imported Timesheet entry should not include ID');
const tsId = this.gateway.ownedRefAsId(src['session']);
await this.gateway.domain.write(async state => {
await this.checkAccess(state, { id: tsId, forWrite: 'Timesheet' });
if (await this.gateway.isGenesisTs(state, tsId))
throw new BadRequestError('Timesheet not found: %s', tsId);
const tsClone = await this.gateway.initTimesheet(tsId, false);
await tsClone.write(async state => {
const tsIri = tsId.toIri();
// Create session in timesheet if required
if (!(tsIri in sessions))
state = await state.write((sessions[tsIri] = new Session()).toJSON());
// Check if a given entry ID already exists
if (src['external'] != null) {
const existing = (await state.read({
'@select': '?e', '@where': { '@id': '?e', external: src['external'] }
})).map(result => result['?e']);
if (existing.length) {
return state.write({
'@delete': existing,
'@insert': {
...src,
...existing[0], // Pick one
'session': { '@id': sessions[tsIri].id }
}
});
}
}
return state.write({
...src,
'@id': `${sessions[tsIri].id}/${sessions[tsIri].claimEntryId()}`,
'session': { '@id': sessions[tsIri].id }
});
});
});
}
toJSON() {
return {
'@id': this.name, // scoped to gateway domain
'@type': 'Account',
'email': [...this.emails],
'keyid': [...this.keyids],
'vf:primaryAccountable': [...this.admins].map(iri => ({ '@id': iri })),
'timesheet': this.timesheets,
'project': this.projects
};
}
}