addition of new dashboard
This commit is contained in:
558
backend/modules/core/data/agreement.js
Normal file
558
backend/modules/core/data/agreement.js
Normal file
@@ -0,0 +1,558 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAgreement, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getAgreement(client, userId, id, key) {
|
||||
const whereClause = id ? `a.agreement_id_uuid = $1` : `a.agreement_id_uuid = (SELECT agreement_uuid FROM agreement_content WHERE lower(key) = lower($1))`;
|
||||
const whereValue = id ? id : key;
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', a.version,
|
||||
'@context', a.context,
|
||||
'parent', a.parent,
|
||||
'references', (
|
||||
SELECT JSON_AGG(r.uri ORDER BY r.uri)
|
||||
FROM reference r
|
||||
INNER JOIN agreement_reference ar
|
||||
ON r.id = ar.reference_id
|
||||
AND ar.agreement_id = a.id
|
||||
AND (
|
||||
(r.user_id = a.user_id AND ar.user_id = a.user_id)
|
||||
OR
|
||||
(r.user_id IS NULL AND ar.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'agreementId', a.agreement_id_uuid,
|
||||
'created', a.created,
|
||||
'ids', (
|
||||
SELECT JSON_AGG(ari.required_id)
|
||||
FROM agreement_required_id ari
|
||||
WHERE ari.agreement_id = a.id
|
||||
),
|
||||
'purposes', (
|
||||
SELECT JSON_AGG(p.value ORDER BY p.value)
|
||||
FROM purpose p
|
||||
INNER JOIN agreement_purpose ap
|
||||
ON p.id = ap.purpose_id
|
||||
AND ap.agreement_id = a.id
|
||||
AND (
|
||||
(p.user_id = a.user_id AND ap.user_id = a.user_id)
|
||||
OR
|
||||
(p.user_id IS NULL AND ap.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'prohibitions', (
|
||||
SELECT JSON_AGG(c.value ORDER BY c.value)
|
||||
FROM prohibition c
|
||||
INNER JOIN agreement_prohibition ac
|
||||
ON c.id = ac.prohibition_id
|
||||
AND ac.agreement_id = a.id
|
||||
AND (
|
||||
(c.user_id = ac.user_id AND ac.user_id = a.user_id)
|
||||
OR
|
||||
(c.user_id IS NULL AND ac.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'validRoles', (
|
||||
SELECT JSON_AGG(r.value ORDER BY r.value)
|
||||
FROM role r
|
||||
INNER JOIN agreement_role ar
|
||||
ON r.id = ar.role_id
|
||||
AND ar.agreement_id = a.id
|
||||
AND (
|
||||
(r.user_id = ar.user_id AND ar.user_id = a.user_id)
|
||||
OR
|
||||
(r.user_id IS NULL AND ar.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
)
|
||||
) AS record
|
||||
FROM agreement a
|
||||
WHERE ${whereClause}
|
||||
AND (
|
||||
(
|
||||
a.user_id = $2
|
||||
OR a.user_id IS NULL
|
||||
)
|
||||
OR a.public IS TRUE
|
||||
)
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
whereValue,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
const ret = res.rows[0].record;
|
||||
if (!ret.ids) ret.ids = [];
|
||||
if (!ret.purposes) ret.purposes = [];
|
||||
if (!ret.prohibitions) ret.prohibitions = [];
|
||||
if (!ret.validRoles) ret.validRoles = [];
|
||||
if (ret.version == 1) {
|
||||
delete ret.references;
|
||||
} else {
|
||||
delete ret["@context"];
|
||||
delete ret.parent;
|
||||
ret.permitted = ret.purposes;
|
||||
delete ret.purposes;
|
||||
ret.prohibited = ret.prohibitions;
|
||||
delete ret.prohibitions;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws,
|
||||
'role', (
|
||||
SELECT r.value
|
||||
FROM role r
|
||||
WHERE r.id = s.role_id
|
||||
AND r.user_id = $2
|
||||
)
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN agreement a ON s.agreement_id = a.id
|
||||
WHERE a.agreement_id_uuid = $1
|
||||
AND a.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingAgreement = await getAgreement(client, userId, input.agreementId, input.key)
|
||||
if (!existingAgreement) {
|
||||
response.error = 'agreement not found'
|
||||
} else {
|
||||
const data = {
|
||||
agreement: existingAgreement
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.agreementId)
|
||||
for (let x = 0; x < data.signatures.length; x++) {
|
||||
if (data.signatures[x].role === null) {
|
||||
delete data.signatures[x].role;
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.agreementId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, agreement, publicAgreement) {
|
||||
await client.query(`BEGIN`);
|
||||
const isPublic = publicAgreement ? true : false;
|
||||
const res = await client.query(`
|
||||
INSERT INTO agreement (
|
||||
user_id,
|
||||
version,
|
||||
parent,
|
||||
agreement_id_uuid,
|
||||
context,
|
||||
public,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
agreement.version,
|
||||
agreement.parent,
|
||||
agreement.agreementId,
|
||||
agreement["@context"],
|
||||
isPublic,
|
||||
agreement.created,
|
||||
new Date(agreement.created).toISOString(),
|
||||
]);
|
||||
for (const id of agreement.ids) {
|
||||
await client.query(`
|
||||
INSERT INTO agreement_required_id (
|
||||
user_id,
|
||||
agreement_id,
|
||||
required_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
id,
|
||||
]);
|
||||
}
|
||||
if (agreement.references) {
|
||||
for (const reference of agreement.references) {
|
||||
await client.query(`
|
||||
INSERT INTO reference (
|
||||
user_id,
|
||||
uri
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
reference,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_reference (
|
||||
user_id,
|
||||
agreement_id,
|
||||
reference_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM reference
|
||||
WHERE uri = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
const purposes = agreement.version == 1 ? agreement.purposes : agreement.permitted;
|
||||
for (const purpose of purposes) {
|
||||
await client.query(`
|
||||
INSERT INTO purpose (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
purpose,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_purpose (
|
||||
user_id,
|
||||
agreement_id,
|
||||
purpose_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM purpose
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
purpose,
|
||||
]);
|
||||
}
|
||||
const prohibitions = agreement.version == 1 ? agreement.prohibitions : agreement.prohibited;
|
||||
for (const prohibition of prohibitions) {
|
||||
await client.query(`
|
||||
INSERT INTO prohibition (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
prohibition,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_prohibition (
|
||||
user_id,
|
||||
agreement_id,
|
||||
prohibition_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM prohibition
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
prohibition,
|
||||
]);
|
||||
}
|
||||
for (const role of agreement.validRoles) {
|
||||
await client.query(`
|
||||
INSERT INTO role (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
role,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_role (
|
||||
user_id,
|
||||
agreement_id,
|
||||
role_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
role,
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, agreementId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $8
|
||||
AND user_id = $1
|
||||
),
|
||||
$9,
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
agreementId,
|
||||
null,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, uuid) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
delete input.shortNames;
|
||||
if (input.caveats) {
|
||||
input.prohibitions = input.caveats;
|
||||
delete input.caveats;
|
||||
}
|
||||
const agreement = await JlincAgreement.create(input);
|
||||
if (uuid) {
|
||||
agreement.agreementId = uuid;
|
||||
}
|
||||
await save(client, userId, agreement, input.public);
|
||||
for (const uri of (agreement.references || [])) {
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: agreement.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${agreement.agreementId}`,
|
||||
data: agreement,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _agreement) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingAgreement = _agreement || await getAgreement(client, userId, input.agreementId)
|
||||
if (!existingAgreement) {
|
||||
throw new Error('agreement does not exist')
|
||||
}
|
||||
const inputEntity = input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
agreement: existingAgreement,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
role: input.role,
|
||||
}
|
||||
const agreementData = await JlincAgreement.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingAgreement.agreementId, agreementData.signatures);
|
||||
const audit = await JlincAudit.create(agreementData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${agreementData?.agreement?.agreementId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
await putQueue(
|
||||
client,
|
||||
'audit',
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
{
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
},
|
||||
response.data.auditData,
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const created = (await create(input.data, userId, client)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
agreementId: created.agreementId,
|
||||
shortName: input.shortName,
|
||||
role: input.role,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.agreementId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const agreement = {
|
||||
getAgreement,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
259
backend/modules/core/data/audit.js
Normal file
259
backend/modules/core/data/audit.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { stringify, configure } from "safe-stable-stringify";
|
||||
import { createHash } from "crypto";
|
||||
import { entity } from "./entity.js";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
|
||||
function splitJws(jws) {
|
||||
const sections = jws.split('.');
|
||||
if (sections.length !== 3) {
|
||||
throw ('Input must be a JWS.');
|
||||
}
|
||||
const jwt = JSON.parse(Buffer.from(sections[0], 'base64url').toString());
|
||||
if (jwt.alg !== 'EdDSA') {
|
||||
throw ('JWT does not indicate EdDSA');
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(sections[1], 'base64url').toString());
|
||||
const wasSigned = Buffer.from(sections[0] + '.' + sections[1]);
|
||||
const signature = Buffer.from(sections[2], 'base64url');
|
||||
return {
|
||||
jwt,
|
||||
payload,
|
||||
wasSigned,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
function verifyJws(input) {
|
||||
let ret = false;
|
||||
try {
|
||||
if (!input.jws) {
|
||||
throw ('No JWS provided.');
|
||||
}
|
||||
if (!input.publicKey) {
|
||||
throw ('No publicKey provided.');
|
||||
}
|
||||
const publicKey = Buffer.from(input.publicKey, 'base64url');
|
||||
if (publicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES) {
|
||||
throw ('publicKey length must be crypto_sign_PUBLICKEYBYTES (32).');
|
||||
}
|
||||
const providedPublicKey = Buffer.from(input.jws.jwt.jwk.x, 'base64url');
|
||||
if (publicKey.compare(providedPublicKey) !== 0) {
|
||||
ret = false;
|
||||
} else {
|
||||
ret = sodium.crypto_sign_verify_detached(input.jws.signature, input.jws.wasSigned, Buffer.from(input.publicKey, 'base64url'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
function validateSignatures(item, signatures, didDocs) {
|
||||
let res = false;
|
||||
try {
|
||||
for (const signature of signatures) {
|
||||
const didDoc = didDocs.find((didDoc) => didDoc.id === signature.id);
|
||||
if (!didDoc) throw ('DID Document not provided');
|
||||
const vmCreatedDate = new Date(didDoc.verificationMethod[0].created);
|
||||
const signDate = new Date(signature.signedOn)
|
||||
const vmDeactivatedDate = didDoc.verificationMethod[0].deactivated ? new Date(didDoc.verificationMethod[0].deactivated) : null;
|
||||
const validDate = signDate >= vmCreatedDate && (!vmDeactivatedDate || vmDeactivatedDate >= signDate);
|
||||
if (!validDate) throw ('Signature is not valid due to key dates');
|
||||
const split = splitJws(signature.jws);
|
||||
if (stringify(item) !== stringify(split.payload)) throw ('Payload does not match');
|
||||
const validJws = verifyJws({
|
||||
jws: split,
|
||||
publicKey: didDoc.verificationMethod[0].key,
|
||||
});
|
||||
if (!validJws) throw ('Signature is not valid');
|
||||
res = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function validateSignedBefore(item, signatures) {
|
||||
let res = false;
|
||||
try {
|
||||
let issue = false;
|
||||
for (const signature of signatures) {
|
||||
if (signature.signedOn >= item.created) {
|
||||
issue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
res = !issue;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function validateDidsMatch(auditSigs, targetSigs) {
|
||||
let match = true;
|
||||
for (const asig of auditSigs) {
|
||||
let found = false;
|
||||
for (const tsig of targetSigs) {
|
||||
if (tsig.id === asig.id) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function generateDigest(content, length) {
|
||||
if (typeof content === 'object') {
|
||||
content = stringify(content);
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
.update(content)
|
||||
.digest('hex')
|
||||
.slice(0, length);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verify(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const data = {
|
||||
valid: [],
|
||||
invalid: [],
|
||||
};
|
||||
if (!input.didDocs) {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames || []) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
}
|
||||
delete input.shortNames;
|
||||
const organizedById = [];
|
||||
for (const item of input.audits) {
|
||||
const found = organizedById.find((obi) =>
|
||||
item.audit.agreementId && obi.agreementId === item.audit.agreementId ||
|
||||
item.audit.eventId && obi.eventId === item.audit.eventId
|
||||
);
|
||||
if (!found) {
|
||||
const newItem = item.audit.agreementId
|
||||
? {
|
||||
agreementId: item.audit.agreementId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
: {
|
||||
eventId: item.audit.eventId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
organizedById.push(newItem)
|
||||
} else {
|
||||
found.auditRecords.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of organizedById) {
|
||||
const existingItem = item.eventId
|
||||
? await event.getEvent(client, userId, item.eventId, true)
|
||||
: await agreement.getAgreement(client, userId, item.agreementId)
|
||||
const existingSignatures = item.eventId
|
||||
? await event.getSignatures(client, userId, item.eventId)
|
||||
: await agreement.getSignatures(client, userId, item.agreementId)
|
||||
// Does the agreement/event signature verify?
|
||||
let validSignature = false;
|
||||
if (validateSignatures(existingItem, existingSignatures, input.didDocs)) {
|
||||
validSignature = true;
|
||||
}
|
||||
for (const auditRecord of item.auditRecords) {
|
||||
const res = {
|
||||
audit: auditRecord,
|
||||
results: {
|
||||
validId: false,
|
||||
validSignature,
|
||||
validAuditHash: false,
|
||||
validAuditSignature: false,
|
||||
}
|
||||
}
|
||||
// Do the agreement/event IDs match?
|
||||
if (
|
||||
(item.agreementId !== null && auditRecord.audit.agreementId === item.agreementId) ||
|
||||
(item.eventId !== null && auditRecord.audit.eventId === item.eventId)
|
||||
)
|
||||
res.results.validId = true;
|
||||
// Do DID IDs match between audit and target object?
|
||||
res.results.validMatchingDids = validateDidsMatch(auditRecord.signatures, existingSignatures);
|
||||
// Does the audit hash match?
|
||||
// The digest was created from whichever signatures this audit record has
|
||||
const signatures = [];
|
||||
for (const s of auditRecord.signatures) {
|
||||
const existingSignature = existingSignatures.find((es) => es.id === s.id);
|
||||
if (existingSignature) {
|
||||
signatures.push(existingSignature)
|
||||
}
|
||||
}
|
||||
const check = item.eventId
|
||||
? {
|
||||
event: existingItem,
|
||||
signatures,
|
||||
}
|
||||
: {
|
||||
agreement: existingItem,
|
||||
signatures,
|
||||
}
|
||||
const digest = generateDigest(check);
|
||||
if (digest === auditRecord.audit.digest)
|
||||
res.results.validAuditHash = true;
|
||||
// Does the audit signature verify?
|
||||
if (validateSignatures(auditRecord.audit, auditRecord.signatures, input.didDocs)) {
|
||||
res.results.validAuditSignature = true;
|
||||
}
|
||||
const isValid =
|
||||
res.results.validId === true &&
|
||||
res.results.validSignature === true &&
|
||||
res.results.validAuditHash === true &&
|
||||
res.results.validAuditSignature === true;
|
||||
if (isValid) {
|
||||
data.valid.push(res);
|
||||
} else {
|
||||
data.invalid.push(res);
|
||||
}
|
||||
// If an event, has the DID signed the agreement and is that signature valid?
|
||||
if (existingItem.eventId !== null && existingItem.agreementId !== '00000000-0000-0000-0000-000000000000') {
|
||||
const existingAgreement = await agreement.getAgreement(client, userId, existingItem.agreementId);
|
||||
const existingAgreementSignatures = await agreement.getSignatures(client, userId, existingItem.agreementId);
|
||||
res.results.validEventAgreement = validateDidsMatch(auditRecord.signatures, existingAgreementSignatures);
|
||||
res.results.validEventAgreementSignature = false;
|
||||
if (validateSignatures(existingAgreement, existingAgreementSignatures, input.didDocs) && validateSignedBefore(existingItem, existingAgreementSignatures)) {
|
||||
res.results.validEventAgreementSignature = true;
|
||||
}
|
||||
} else {
|
||||
res.results.validEventAgreement = true;
|
||||
res.results.validEventAgreementSignature = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: 'validation complete',
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
verify,
|
||||
}
|
||||
172
backend/modules/core/data/entity.js
Normal file
172
backend/modules/core/data/entity.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { createHash } from "crypto";
|
||||
import { getConfig } from "../../../common/config.js";
|
||||
import axios from "axios";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
async function getEntity(client, userId, shortName) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'fedidUrl', e.fedid_url,
|
||||
'shortName', e.short_name,
|
||||
'didId', e.did_id,
|
||||
'controlPrivateKeyB64U', e.control_private_key_b64u,
|
||||
'recoveryPrivateKeyB64U', e.recovery_private_key_b64u,
|
||||
'didDoc', e.did_doc
|
||||
) AS record
|
||||
FROM entity e
|
||||
WHERE LOWER(e.short_name) = LOWER($1)
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
shortName,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
return res.rows[0].record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEntity = await getEntity(client, userId, input.shortName)
|
||||
if (!existingEntity) {
|
||||
response.error = 'entity not found'
|
||||
} else {
|
||||
const data = {
|
||||
didDoc: existingEntity.didDoc,
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.shortName}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function getDomains(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const data = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
response = {
|
||||
message: `retrieved domains`,
|
||||
data,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, entity) {
|
||||
const res = await client.query(`
|
||||
INSERT INTO entity (
|
||||
user_id,
|
||||
fedid_url,
|
||||
short_name,
|
||||
did_id,
|
||||
control_private_key_b64u,
|
||||
recovery_private_key_b64u,
|
||||
did_doc
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
entity.fedidUrl,
|
||||
entity.shortName,
|
||||
entity.didDoc.id,
|
||||
entity.controlPrivateKeyB64U,
|
||||
entity.recoveryPrivateKeyB64U,
|
||||
entity.didDoc,
|
||||
]);
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const domains = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
const shortName = input.shortName.split('@')
|
||||
const domain = shortName[1]
|
||||
if (!domains.includes(domain)) {
|
||||
throw new Error('domain is not available');
|
||||
}
|
||||
const entity = {
|
||||
shortName: input.shortName,
|
||||
fedidUrl,
|
||||
}
|
||||
// Generate a control key
|
||||
entity.controlPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.controlPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
sodium.crypto_sign_keypair(entity.controlPublicKey, entity.controlPrivateKey);
|
||||
entity.controlPublicKeyB64U = entity.controlPublicKey.toString("base64url");
|
||||
entity.controlPrivateKeyB64U = entity.controlPrivateKey.toString("base64url");
|
||||
// Generate a recovery key
|
||||
entity.recoveryPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.recoveryPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
entity.recoveryPrivateKeyB64U = entity.recoveryPrivateKey.toString("base64url");
|
||||
sodium.crypto_sign_keypair(entity.recoveryPublicKey, entity.recoveryPrivateKey);
|
||||
entity.recoveryHash = createHash("sha256").update(entity.recoveryPublicKey).digest("hex").slice(0, 48);
|
||||
entity.didDoc = (await axios.post(
|
||||
`${fedidUrl}/api/v2/did/create`,
|
||||
{
|
||||
shortName: entity.shortName,
|
||||
control: entity.controlPublicKeyB64U,
|
||||
recoveryHash: entity.recoveryHash,
|
||||
},
|
||||
)).data.data.didDoc;
|
||||
await save(client, userId, entity);
|
||||
response = {
|
||||
message: `created and saved: ${entity.didDoc.id}`,
|
||||
data: {
|
||||
didDoc: entity.didDoc,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const entity = {
|
||||
getEntity,
|
||||
getDomains,
|
||||
get,
|
||||
create,
|
||||
}
|
||||
413
backend/modules/core/data/event.js
Normal file
413
backend/modules/core/data/event.js
Normal file
@@ -0,0 +1,413 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincEvent, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getEvent(client, userId, id, includeData, meta) {
|
||||
const dataSql = includeData
|
||||
? `
|
||||
'data', (
|
||||
SELECT ed.data
|
||||
FROM event_data ed
|
||||
WHERE ed.event_id = e.id
|
||||
),
|
||||
`
|
||||
: ``;
|
||||
let fields = [userId];
|
||||
let count = fields.length + 1;
|
||||
let whereAnd = ``
|
||||
if (id) {
|
||||
whereAnd += ` AND e.event_id_uuid = $${count++}`;
|
||||
fields.push(id);
|
||||
}
|
||||
if (meta) {
|
||||
let whereInVals = ``
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
if (whereInVals != ``)
|
||||
whereInVals = ` AND `
|
||||
whereInVals += `(em.key = $${count++} AND em.value = $${count++})`;
|
||||
fields.push(key);
|
||||
fields.push(value);
|
||||
}
|
||||
whereAnd += `
|
||||
AND e.id IN (
|
||||
SELECT em.event_id
|
||||
FROM event_meta em
|
||||
WHERE em.user_id = $1
|
||||
AND ${whereInVals}
|
||||
)
|
||||
`
|
||||
}
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', e.version,
|
||||
'eventId', e.event_id_uuid,
|
||||
'type', (
|
||||
SELECT et.value
|
||||
FROM event_type et
|
||||
WHERE et.id = e.event_type_id
|
||||
),
|
||||
'senderId', e.sender_id,
|
||||
'recipientId', e.recipient_id,
|
||||
'created', e.created,
|
||||
'agreementId', (
|
||||
SELECT a.agreement_id_uuid
|
||||
FROM agreement a
|
||||
WHERE a.id = e.agreement_id
|
||||
AND a.user_id = $1
|
||||
),
|
||||
${dataSql}
|
||||
'created', e.created
|
||||
) AS record
|
||||
FROM event e
|
||||
WHERE e.user_id = $1
|
||||
${whereAnd};
|
||||
`
|
||||
const res = await client.query(sql, fields);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
const ret = res.rows[0].record;
|
||||
if (ret.data) {
|
||||
try {
|
||||
ret.data = JSON.parse(ret.data);
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN event e ON s.event_id = e.id
|
||||
WHERE e.event_id_uuid = $1
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEvent = await getEvent(client, userId, input.eventId, true, input.meta)
|
||||
if (!existingEvent) {
|
||||
response.error = 'event not found'
|
||||
} else {
|
||||
const data = {
|
||||
event: existingEvent
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.eventId)
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${existingEvent.eventId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, event, meta) {
|
||||
await client.query(`BEGIN`);
|
||||
const res = await client.query(`
|
||||
INSERT INTO event (
|
||||
user_id,
|
||||
version,
|
||||
event_id_uuid,
|
||||
event_type_id,
|
||||
agreement_id,
|
||||
sender_id,
|
||||
recipient_id,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
(
|
||||
SELECT id
|
||||
FROM event_type
|
||||
WHERE value = $4
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $5
|
||||
),
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
event.version,
|
||||
event.eventId,
|
||||
event.type,
|
||||
event.agreementId,
|
||||
event.senderId,
|
||||
event.recipientId,
|
||||
event.created,
|
||||
new Date(event.created).toISOString(),
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO event_data (
|
||||
user_id,
|
||||
event_id,
|
||||
data
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
event.data,
|
||||
]);
|
||||
if (meta) {
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
await client.query(`
|
||||
INSERT INTO event_meta (
|
||||
user_id,
|
||||
event_id,
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
key,
|
||||
value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, eventId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
$8,
|
||||
(
|
||||
SELECT id
|
||||
FROM event
|
||||
WHERE event_id_uuid = $9
|
||||
AND user_id = $1
|
||||
),
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
null,
|
||||
eventId,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, _sender) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.senderId = _sender ? _sender.didDoc.id : (await entity.getEntity(client, userId, input.senderShortName)).didDoc.id
|
||||
input.recipientId = (await entity.getEntity(client, userId, input.recipientShortName)).didDoc.id
|
||||
delete input.senderShortName
|
||||
delete input.recipientShortName
|
||||
const event = await JlincEvent.create(input);
|
||||
await save(client, userId, event, input.meta);
|
||||
// Pull down any referenced agreement content now (request refs are URIs,
|
||||
// permission refs are { reference, permitted }), so a get never has to.
|
||||
for (const ref of (event.references || [])) {
|
||||
const uri = typeof ref === "string" ? ref : ref?.reference;
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: event.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${event.eventId}`,
|
||||
data: event,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _event, _sender, meta) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingEvent = _event || await getEvent(client, userId, input.eventId, true)
|
||||
if (!existingEvent) {
|
||||
throw new Error('event does not exist')
|
||||
}
|
||||
const inputEntity = _sender || input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
event: existingEvent,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const eventData = await JlincEvent.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingEvent.eventId, eventData.signatures);
|
||||
const audit = await JlincAudit.create(eventData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${eventData?.event?.eventId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
if (meta) {
|
||||
response.data.auditData.meta = meta;
|
||||
}
|
||||
await putQueue(
|
||||
client,
|
||||
'audit',
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
{
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
},
|
||||
response.data.auditData,
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const shortName = input.senderShortName;
|
||||
const sender = (await entity.getEntity(client, userId, input.senderShortName))
|
||||
const created = (await create(input, userId, client, sender)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
eventId: created.eventId,
|
||||
shortName,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
sender,
|
||||
input.meta,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.eventId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const event = {
|
||||
getEvent,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
12
backend/modules/core/data/index.js
Normal file
12
backend/modules/core/data/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { audit } from "./audit.js";
|
||||
|
||||
|
||||
export const data = {
|
||||
agreement,
|
||||
event,
|
||||
entity,
|
||||
audit,
|
||||
}
|
||||
Reference in New Issue
Block a user