add auth functionality

This commit is contained in:
2025-12-08 14:28:07 +00:00
parent 2c0af8e237
commit 44cd64a1db
34 changed files with 3463 additions and 7056 deletions

View File

@@ -1,4 +1,4 @@
FROM node:20 FROM node:25
ADD src/package*.json /app/ ADD src/package*.json /app/
WORKDIR /app WORKDIR /app
RUN npm install RUN npm install

View File

@@ -59,6 +59,14 @@ services:
# - Set up auth: https://console.cloud.google.com/auth/overview # - Set up auth: https://console.cloud.google.com/auth/overview
# GOOGLE_CLIENT_ID: # GOOGLE_CLIENT_ID:
# GOOGLE_CLIENT_SECRET: # GOOGLE_CLIENT_SECRET:
# PDP MODULES
# ===========
PDP_TYPE: cerbos
# Cerbos
# -----------
PDP_URL: http://jlinc-pdp:3592
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- jlinc-db - jlinc-db
@@ -83,5 +91,16 @@ services:
networks: networks:
- jlinc - jlinc
jlinc-pdp:
image: cerbos/cerbos:0.47.0
container_name: jlinc-pdp
ports:
- 127.0.0.1:3592:3592
volumes:
- ./policies:/policies
restart: unless-stopped
networks:
- jlinc
networks: networks:
jlinc: jlinc:

41
policies/data.yaml Normal file
View File

@@ -0,0 +1,41 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: data
version: default
rules:
- actions:
- create
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- user
- admin
- thirdParty
- actions:
- update
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- admin
# This is an example of using conditions for attribute-based access control
# The action is only allowed if the principal ID matches the ownerId attribute
# - actions:
# - someAction
# effect: EFFECT_ALLOW
# roles:
# - user
# condition:
# match:
# expr: request.resource.attr.ownerId == request.principal.id

41
policies/data_test.yaml Normal file
View File

@@ -0,0 +1,41 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/TestSuite.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/compile#testing
name: dataTestSuite
description: Tests for verifying the data resource policy
tests:
- name: data actions
input:
principals:
- user#1
- admin#2
- thirdParty#3
resources:
- data#1
actions:
- create
- read
- update
- delete
expected:
- resource: data#1
principal: user#1
actions:
create: EFFECT_DENY
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY
- resource: data#1
principal: admin#2
actions:
create: EFFECT_ALLOW
read: EFFECT_ALLOW
update: EFFECT_ALLOW
delete: EFFECT_ALLOW
- resource: data#1
principal: thirdParty#3
actions:
create: EFFECT_DENY
read: EFFECT_ALLOW
update: EFFECT_DENY
delete: EFFECT_DENY

40
policies/privateData.yaml Normal file
View File

@@ -0,0 +1,40 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: privateData
version: default
rules:
- actions:
- create
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- user
- actions:
- update
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- admin
# This is an example of using conditions for attribute-based access control
# The action is only allowed if the principal ID matches the ownerId attribute
# - actions:
# - someAction
# effect: EFFECT_ALLOW
# roles:
# - admin
# condition:
# match:
# expr: request.resource.attr.ownerId == request.principal.id

View File

@@ -0,0 +1,40 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: privateData
version: default
rules:
- actions:
- create
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- user
- actions:
- update
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- admin
# This is an example of using conditions for attribute-based access control
# The action is only allowed if the principal ID matches the ownerId attribute
# - actions:
# - someAction
# effect: EFFECT_ALLOW
# roles:
# - admin
# condition:
# match:
# expr: request.resource.attr.ownerId == request.principal.id

19
policies/testdata/principals.yaml vendored Normal file
View File

@@ -0,0 +1,19 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/TestFixture/Principals.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/compile#_sharing_test_fixtures
principals:
user#1:
id: user#1
roles:
- user
attr: {}
admin#2:
id: admin#2
roles:
- admin
attr: {}
thirdParty#3:
id: thirdParty#3
roles:
- thirdParty
attr: {}

12
policies/testdata/resources.yaml vendored Normal file
View File

@@ -0,0 +1,12 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/TestFixture/Resources.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/compile#_sharing_test_fixtures
resources:
data#1:
id: data#1
kind: data
attr: {}
privateData#2:
id: privateData#2
kind: privateData
attr: {}

View File

@@ -32,6 +32,8 @@ export async function loadConfig() {
const { getModuleConfig } = await import(appModulePath); const { getModuleConfig } = await import(appModulePath);
config.appModules[type] = getModuleConfig(); config.appModules[type] = getModuleConfig();
} }
if (process.env.PDP_TYPE) config.pdpType = process.env.PDP_TYPE;
if (process.env.PDP_URL) config.pdpUrl = process.env.PDP_URL;
} }
export function getConfig() { export function getConfig() {

View File

@@ -0,0 +1,4 @@
{
"purposes": [],
"prohibitions": []
}

View File

@@ -131,9 +131,12 @@ export async function populateAgreements() {
withFileTypes: true, withFileTypes: true,
}); });
for await (const file of files) { for await (const file of files) {
const agreementId = file.name.slice(0, 36); if (file.name.endsWith('.json'))
continue;
const agreementUuid = file.name.slice(0, 36);
const title = file.name.slice(39, file.name.length - 3); const title = file.name.slice(39, file.name.length - 3);
const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim(); const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim();
const json = JSON.parse(fs.readFileSync(path.join("./db/agreements", file.name.replace('.md', '.json')), "utf8").trim());
const hash = createHash('sha256') const hash = createHash('sha256')
.update(markdown) .update(markdown)
.digest('hex') .digest('hex')
@@ -141,40 +144,48 @@ export async function populateAgreements() {
const agreementExists = await client.query(` const agreementExists = await client.query(`
SELECT SELECT
CASE CASE
WHEN (SELECT COUNT(1) FROM agreement WHERE agreement_id_uuid = $1 AND user_id IS NULL) > 0 WHEN (SELECT COUNT(1) FROM agreement_content WHERE title = $1 AND hash = $2 AND user_id IS NULL) > 0
THEN TRUE THEN TRUE
ELSE FALSE ELSE FALSE
END AS exists END AS exists
`, `,
[ [
agreementId, title,
hash,
] ]
); );
if (!agreementExists.rows[0].exists) { if (!agreementExists.rows[0].exists) {
console.log(`Adding agreement '${title}' (${agreementId})`); console.log(`Adding agreement '${title}' (${agreementUuid})`);
await client.query(` await client.query(`
INSERT INTO agreement_content ( INSERT INTO agreement_content (
title, title,
markdown, markdown,
hash hash,
key,
agreement_uuid
) VALUES ( ) VALUES (
$1, $1,
$2, $2,
$3 $3,
) ON CONFLICT DO NOTHING; $4,
$5
);
`, [ `, [
title, title,
markdown, markdown,
hash, hash,
json.key,
agreementUuid,
]); ]);
const agreement = { const agreement = {
"@context": json["@context"] || 'https://protocol.jlinc.org/context/jlinc-v7.jsonld',
uri: `${config.publicCoreUrl}/agreements/${hash}`, uri: `${config.publicCoreUrl}/agreements/${hash}`,
purposes: [], purposes: json.purposes || [],
caveats: [], prohibitions: json.prohibitions || [],
shortNames: [], shortNames: [],
validRoles: [], validRoles: json.validRoles || [],
} }
await data.agreement.create(agreement, null, client, agreementId); await data.agreement.create(agreement, null, client, agreementUuid);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@@ -0,0 +1,46 @@
ALTER TABLE public.caveat RENAME TO prohibition;
ALTER TABLE public.prohibition RENAME CONSTRAINT uniq__caveat TO uniq__prohibition;
ALTER INDEX idx__public__caveat__user_id RENAME TO idx__public__prohibition__user_id;
ALTER INDEX idx__public__caveat__value RENAME TO idx__public__prohibition__value;
ALTER INDEX idx__public__caveat__created_ts RENAME TO idx__public__prohibition__created_ts;
ALTER INDEX idx__public__caveat__updated_ts RENAME TO idx__public__prohibition__updated_ts;
ALTER TABLE public.agreement_caveat RENAME TO agreement_prohibition;
ALTER TABLE public.agreement_prohibition RENAME CONSTRAINT uniq__agreement_caveat TO uniq__agreement_prohibition;
ALTER TABLE public.agreement_prohibition RENAME COLUMN caveat_id TO prohibition_id;
ALTER TABLE public.agreement_prohibition
DROP CONSTRAINT IF EXISTS agreement_caveat_caveat_id_fkey,
ADD CONSTRAINT agreement_prohibition_prohibition_id_fkey
FOREIGN KEY (prohibition_id)
REFERENCES public.prohibition(id);
ALTER INDEX idx__public__agreement_caveat__user_id RENAME TO idx__public__agreement_prohibition__user_id;
ALTER INDEX idx__public__agreement_caveat__agreement_id RENAME TO idx__public__agreement_prohibition__agreement_id;
ALTER INDEX idx__public__agreement_caveat__caveat_id RENAME TO idx__public__agreement_prohibition__prohibition_id;
ALTER INDEX idx__public__agreement_caveat__created_ts RENAME TO idx__public__agreement_prohibition__created_ts;
ALTER INDEX idx__public__agreement_caveat__updated_ts RENAME TO idx__public__agreement_prohibition__updated_ts;
ALTER TABLE agreement_content ADD COLUMN key VARCHAR(255) UNIQUE;
CREATE INDEX idx__agreement_content__key ON public.agreement_content (key);
ALTER TABLE agreement_content ADD COLUMN agreement_uuid UUID UNIQUE NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
ALTER TABLE purpose ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE prohibition ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE role ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_purpose ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_prohibition ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_role ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.purpose DROP CONSTRAINT uniq__purpose;
CREATE UNIQUE INDEX uniq__purpose ON public.purpose (COALESCE(user_id, -1), value);
ALTER TABLE public.prohibition DROP CONSTRAINT uniq__prohibition;
CREATE UNIQUE INDEX uniq__prohibition ON public.prohibition (COALESCE(user_id, -1), value);
ALTER TABLE public.role DROP CONSTRAINT uniq__role;
CREATE UNIQUE INDEX uniq__role ON public.role (COALESCE(user_id, -1), value);
ALTER TABLE public.agreement_purpose DROP CONSTRAINT uniq__agreement_purpose;
CREATE UNIQUE INDEX uniq__agreement_purpose ON public.agreement_purpose (COALESCE(user_id, -1), agreement_id, purpose_id);
ALTER TABLE public.agreement_prohibition DROP CONSTRAINT uniq__agreement_prohibition;
CREATE UNIQUE INDEX uniq__agreement_prohibition ON public.agreement_prohibition (COALESCE(user_id, -1), agreement_id, prohibition_id);
ALTER TABLE public.agreement_role DROP CONSTRAINT uniq__agreement_role;
CREATE UNIQUE INDEX uniq__agreement_role ON public.agreement_role (COALESCE(user_id, -1), agreement_id, role_id);
ALTER TABLE public.agreement ADD COLUMN context VARCHAR(255) DEFAULT 'https://protocol.jlinc.org/context/jlinc-v7.jsonld';

View File

@@ -26,7 +26,40 @@ async function getAgreementContent(userId, hash) {
} finally { } finally {
await client.release(); await client.release();
} }
return marked(content); return content;
}
export async function getAgreements(userId) {
let agreements = [];
const client = await getPool();
try {
let sql = `
SELECT
title,
hash
FROM agreement_content
WHERE user_id IS null
`;
let values = [];
if (userId) {
sql += `
OR user_id = $1
`;
values.push(userId);
}
sql += `
ORDER BY title ASC
`
const res = await client.query(sql, values);
if (res.rows.length > 0) {
agreements = res.rows;
}
} catch(e) {
console.error(e)
} finally {
await client.release();
}
return agreements;
} }
export function routeAgreements(app) { export function routeAgreements(app) {
@@ -34,14 +67,30 @@ export function routeAgreements(app) {
app.get('/agreements/:hash', async (req, res) => { app.get('/agreements/:hash', async (req, res) => {
const { hash } = req.params; const { hash } = req.params;
const agreement = await getAgreementContent(null, hash); const agreement = await getAgreementContent(null, hash);
res.send(agreement); res.render('agreement', {
agreement: marked(agreement),
rawUrl: `/agreements/${hash}/raw`,
});
});
app.get('/agreements/:hash/raw', async (req, res) => {
const { hash } = req.params;
const agreement = await getAgreementContent(null, hash);
res.send(`<pre>${agreement}</pre>`);
}); });
// Route for /agreements/:userid/:hash
app.get('/agreements/:userId/:hash', async (req, res) => { app.get('/agreements/:userId/:hash', async (req, res) => {
const { userId, hash } = req.params; const { userId, hash } = req.params;
const agreement = await getAgreementContent(userId, hash); const agreement = await getAgreementContent(userId, hash);
res.send(agreement); res.render('agreement', {
agreement: marked(agreement),
rawUrl: `/agreements/${hash}/raw`,
});
}); });
app.get('/agreements/:userId/:hash/raw', async (req, res) => {
const { hash } = req.params;
const agreement = await getAgreementContent(userId, hash);
res.send(`<pre>${agreement}</pre>`);
});
} }

View File

@@ -1,9 +1,11 @@
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import { initModules, apiMiddleware } from "./auth.js"; import { initModules, apiMiddleware } from "./auth.js";
import { loadPep } from "../modules/pep/index.js";
import swaggerUi from "swagger-ui-express"; import swaggerUi from "swagger-ui-express";
import swaggerDocument from "./api/v1/swagger.json" assert { type: "json" }; import swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
import { getConfig } from "../common/config.js"; import { getConfig } from "../common/config.js";
import { core } from "../modules/core/index.js" import { core } from "../modules/core/index.js"
import { getAgreements } from "../http/agreements.js"
import express from "express"; import express from "express";
import session from "express-session"; import session from "express-session";
@@ -34,8 +36,10 @@ async function renderPrivate(view, req, res, config) {
const begin = new Date(now.getFullYear(), now.getMonth(), 1); const begin = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const usage = await getUsage(req.user, begin, end); const usage = await getUsage(req.user, begin, end);
const agreements = await getAgreements(req?.user?.id);
res.render(view, { res.render(view, {
config, config,
agreements,
user: req.user, user: req.user,
usage, usage,
}); });
@@ -80,12 +84,14 @@ export async function initHTTP(app) {
logRequest(app); logRequest(app);
routeAgreements(app); routeAgreements(app);
await loadPep(app);
app.get("/", (req, res) => render('login', res, config)); app.get("/", (req, res) => render('login', res, config));
app.get("/dashboard", (req, res) => renderPrivate('dashboard', req, res, config)); app.get("/dashboard", (req, res) => renderPrivate('dashboard', req, res, config));
app.get("/refresh", refresh); app.get("/refresh", refresh);
app.post('/logout', logout); app.post('/logout', logout);
app.post("/api/v1/*", bodyParser.json(), apiMiddleware, core.post); app.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post);
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); // app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
} }

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<%- include('./include/header.ejs', { title: 'JLINC - MyTerms Agreement' }) %>
<style>
a {
color: #31A9BA !important
}
</style>
<div class="mdc-card" style="background: linear-gradient(333deg, rgb(0, 0, 0) 0%, rgb(79, 55, 139) 100%);">
<%- agreement %>
</div>
<br>
View the <a href="<%- rawUrl %>">raw agreement content</a>.
<%- include('./include/footer.ejs') %>

View File

@@ -12,4 +12,10 @@
%> %>
<% } %> <% } %>
<%-
include('./include/agreements.ejs', {
app: config.appModules['core']
})
%>
<%- include('./include/footer.ejs') %> <%- include('./include/footer.ejs') %>

View File

@@ -0,0 +1,26 @@
<% const cardStyle=`"background: linear-gradient(333deg, ${app.background.color1} 0%, ${app.background.color2} 100%);
margin-bottom: 30px;"`; const userApp=user.apps.find(ua=> app.type === 'core');
const buttonStyle = `"padding: 8px 10px 8px 10px; border-radius: 16px !important; background-color:
${app.button.color} !important; border: none !important;"`
%>
<div class="mdc-card mdc-theme--dark" style=<%- cardStyle %>>
<div style="display: flex; align-items: center;">
<div style="width: 30px">
<%- app.logo %>
</div>
<h2 style="margin-left: 10px; padding-bottom: 10px;">
Available Agreements
</h2>
</div>
<div style="display: flex; align-items: center;">
<ul style="margin-top: 0px">
<% for (const agreement of agreements) { %>
<li style="padding-bottom: 1em"><a target="_new" class="agreement-link" href="/agreements/<%- agreement.hash %>"><%- agreement.title %></a></li>
<% } %>
</ul>
</div>
</div>

View File

@@ -1,3 +1,5 @@
</div>
</center>
</div> </div>
</body> </body>

View File

@@ -64,6 +64,11 @@
/* @include button.ink-color(#84565E); */ /* @include button.ink-color(#84565E); */
} }
.agreement-link {
font-family: var(--md-ref-typeface-plain);
color: white;
}
</style> </style>
</head> </head>
@@ -109,10 +114,12 @@
</script> </script>
<% } %> <% } %>
</div> </div>
<div style="width: 90%; max-width: 600px;"> <div style="width: 100%; overflow-y: auto; max-height: 100%; word-wrap: break-word;">
<div <center>
style="display: flex; align-items: center; justify-content: center; gap: 0px; text-align: center; transform: translateX(-0px);"> <div style="width: 90%; max-width: 600px; text-align: left">
<div style="width: 200px; padding-bottom: 26px"> <div
<%- include('./logo-white.svg') %> style="padding-top: 20px; display: flex; align-items: center; justify-content: center; gap: 0px; text-align: center; transform: translateX(-0px);">
</div> <div style="width: 200px; padding-bottom: 26px">
</div> <%- include('./logo-white.svg') %>
</div>
</div>

View File

@@ -7,13 +7,15 @@
<h3>Login with:</h3> <h3>Login with:</h3>
<% for (const type in config.authModules) { %> <% for (const type in config.authModules) { %>
<div style="width: 100%; padding-bottom: 16px"> <% if (type !== 'single') { %>
<button style="width: 100%" class="mdc-button mdc-button--raised mdc-button--leading" onclick="window.location.href='/login/<%= type %>'"> <div style="width: 100%; padding-bottom: 16px">
<span class="mdc-button__ripple"></span> <button style="width: 100%" class="mdc-button mdc-button--raised mdc-button--leading" onclick="window.location.href='/login/<%= type %>'">
<i class="material-icons mdc-button__icon" aria-hidden="true"><%= config.authModules[type].icon %></i> <span class="mdc-button__ripple"></span>
<span class="mdc-button__label"><%= config.authModules[type].title %></span> <i class="material-icons mdc-button__icon" aria-hidden="true"><%= config.authModules[type].icon %></i>
</button> <span class="mdc-button__label"><%= config.authModules[type].title %></span>
</div> </button>
</div>
<% } %>
<% } %> <% } %>
</div> </div>

View File

@@ -3,6 +3,10 @@ const { JlincAgreement } = pkg;
async function create(input) { async function create(input) {
if (data.caveats) {
data.prohibitions = data.caveats;
delete data.caveats;
}
const data = await JlincAgreement.create(input); const data = await JlincAgreement.create(input);
const message = data?.agreementId; const message = data?.agreementId;
return { return {

View File

@@ -4,11 +4,14 @@ import { getPool } from "../../../db/index.js";
import { entity } from "./entity.js"; import { entity } from "./entity.js";
import { putQueue } from "../../../common/queue.js"; import { putQueue } from "../../../common/queue.js";
async function getAgreement(client, userId, id) { 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 = ` const sql = `
SELECT SELECT
JSON_BUILD_OBJECT( JSON_BUILD_OBJECT(
'version', a.version, 'version', a.version,
'@context', a.context,
'parent', a.parent, 'parent', a.parent,
'agreementId', a.agreement_id_uuid, 'agreementId', a.agreement_id_uuid,
'created', a.created, 'created', a.created,
@@ -20,41 +23,58 @@ async function getAgreement(client, userId, id) {
'purposes', ( 'purposes', (
SELECT JSON_AGG(p.value ORDER BY p.value) SELECT JSON_AGG(p.value ORDER BY p.value)
FROM purpose p FROM purpose p
LEFT JOIN agreement_purpose ap INNER JOIN agreement_purpose ap
ON p.id = ap.purpose_id ON p.id = ap.purpose_id
AND ap.agreement_id = a.id AND ap.agreement_id = a.id
AND p.user_id = a.user_id AND (
AND ap.user_id = a.user_id (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)
)
), ),
'caveats', ( 'prohibitions', (
SELECT JSON_AGG(c.value ORDER BY c.value) SELECT JSON_AGG(c.value ORDER BY c.value)
FROM caveat c FROM prohibition c
LEFT JOIN agreement_caveat ac INNER JOIN agreement_prohibition ac
ON c.id = ac.caveat_id ON c.id = ac.prohibition_id
AND ac.agreement_id = a.id AND ac.agreement_id = a.id
AND c.user_id = a.user_id AND (
AND ac.user_id = a.user_id (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', ( 'validRoles', (
SELECT JSON_AGG(r.value ORDER BY r.value) SELECT JSON_AGG(r.value ORDER BY r.value)
FROM role r FROM role r
LEFT JOIN agreement_role ar INNER JOIN agreement_role ar
ON r.id = ar.role_id ON r.id = ar.role_id
AND ar.agreement_id = a.id AND ar.agreement_id = a.id
AND r.user_id = a.user_id AND (
AND ar.user_id = a.user_id (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 ) AS record
FROM agreement a FROM agreement a
WHERE a.agreement_id_uuid = $1 WHERE ${whereClause}
AND a.user_id = $2; AND (
a.user_id = $2
OR a.user_id IS NULL
)
` `
const res = await client.query(sql, [ const res = await client.query(sql, [
id, whereValue,
userId, userId,
]); ]);
if (res.rows.length > 0 && res.rows[0].record) { if (res.rows.length > 0 && res.rows[0].record) {
return 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 = [];
return ret;
} }
return null; return null;
} }
@@ -99,7 +119,7 @@ async function get(input, userId) {
}; };
const client = await getPool(); const client = await getPool();
try { try {
const existingAgreement = await getAgreement(client, userId, input.agreementId) const existingAgreement = await getAgreement(client, userId, input.agreementId, input.key)
if (!existingAgreement) { if (!existingAgreement) {
response.error = 'agreement not found' response.error = 'agreement not found'
} else { } else {
@@ -135,6 +155,7 @@ async function save(client, userId, agreement) {
version, version,
parent, parent,
agreement_id_uuid, agreement_id_uuid,
context,
created, created,
created_as_ts created_as_ts
) VALUES ( ) VALUES (
@@ -143,13 +164,15 @@ async function save(client, userId, agreement) {
$3, $3,
$4, $4,
$5, $5,
$6 $6,
$7
) RETURNING id; ) RETURNING id;
`, [ `, [
userId, userId,
agreement.version, agreement.version,
agreement.parent, agreement.parent,
agreement.agreementId, agreement.agreementId,
agreement["@context"],
agreement.created, agreement.created,
new Date(agreement.created).toISOString(), new Date(agreement.created).toISOString(),
]); ]);
@@ -195,7 +218,7 @@ async function save(client, userId, agreement) {
SELECT id SELECT id
FROM purpose FROM purpose
WHERE value = $3 WHERE value = $3
AND user_id = $1 AND user_id ${userId ? `= $1` : `IS NULL`}
) )
); );
`, [ `, [
@@ -204,9 +227,9 @@ async function save(client, userId, agreement) {
purpose, purpose,
]); ]);
} }
for (const caveat of agreement.caveats) { for (const prohibition of agreement.prohibitions) {
await client.query(` await client.query(`
INSERT INTO caveat ( INSERT INTO prohibition (
user_id, user_id,
value value
) VALUES ( ) VALUES (
@@ -215,27 +238,27 @@ async function save(client, userId, agreement) {
) ON CONFLICT DO NOTHING; ) ON CONFLICT DO NOTHING;
`, [ `, [
userId, userId,
caveat, prohibition,
]); ]);
await client.query(` await client.query(`
INSERT INTO agreement_caveat ( INSERT INTO agreement_prohibition (
user_id, user_id,
agreement_id, agreement_id,
caveat_id prohibition_id
) VALUES ( ) VALUES (
$1, $1,
$2, $2,
( (
SELECT id SELECT id
FROM caveat FROM prohibition
WHERE value = $3 WHERE value = $3
AND user_id = $1 AND user_id ${userId ? `= $1` : `IS NULL`}
) )
); );
`, [ `, [
userId, userId,
res.rows[0].id, res.rows[0].id,
caveat, prohibition,
]); ]);
} }
for (const role of agreement.validRoles) { for (const role of agreement.validRoles) {
@@ -263,7 +286,7 @@ async function save(client, userId, agreement) {
SELECT id SELECT id
FROM role FROM role
WHERE value = $3 WHERE value = $3
AND user_id = $1 AND user_id ${userId ? `= $1` : `IS NULL`}
) )
); );
`, [ `, [
@@ -340,6 +363,10 @@ async function create(input, userId, _client, uuid) {
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc) input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
} }
delete input.shortNames; delete input.shortNames;
if (input.caveats) {
input.prohibitions = input.caveats;
delete input.caveats;
}
const agreement = await JlincAgreement.create(input); const agreement = await JlincAgreement.create(input);
if (uuid) { if (uuid) {
agreement.agreementId = uuid; agreement.agreementId = uuid;

View File

@@ -6,156 +6,186 @@ import { data } from "./data/index.js";
import { archive } from "./archive.js"; import { archive } from "./archive.js";
import { trackUsage } from "./usage.js"; import { trackUsage } from "./usage.js";
import { getConfig } from "../../common/config.js"; import { getConfig } from "../../common/config.js";
import { evaluate } from "../pep/index.js";
async function post(req, res) { async function post(req, res) {
let response = { let response = {
success: false, success: false,
error: 'Unknown error', error: 'Unknown error',
}; };
let errorCode = 400;
let type; let type;
const prefix = `${req.method} ${req.url}`; const prefix = `${req.method} ${req.url}`;
let evaluation = false;
try { try {
const input = req.body; const input = req.body;
if (input.auth) {
evaluation = await evaluate(input.auth);
} else {
evaluation = true;
}
const config = getConfig(); const config = getConfig();
if (Object.keys(config.appModules).includes('core')) { if (Object.keys(config.appModules).includes('core')) {
switch (req.url) { switch (req.url) {
case '/api/v1/auth':
evaluation = await evaluate(input);
if (evaluation)
response = {
data: {
decision: true
},
message: 'Authorization allowed'
};
type = 'core';
break;
case '/api/v1/did/create': case '/api/v1/did/create':
response = await did.create(input); if (evaluation) response = await did.create(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/did/rotate': case '/api/v1/did/rotate':
response = await did.rotate(input); if (evaluation) response = await did.rotate(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/did/updateServices': case '/api/v1/did/updateServices':
response = await did.updateServices(input); if (evaluation) response = await did.updateServices(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/did/send': case '/api/v1/did/send':
response = await did.send(input); if (evaluation) response = await did.send(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/did/resolve': case '/api/v1/did/resolve':
response = await did.resolve(input); if (evaluation) response = await did.resolve(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/agreement/create': case '/api/v1/agreement/create':
response = await agreement.create(input); if (evaluation) response = await agreement.create(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/agreement/sign': case '/api/v1/agreement/sign':
response = await agreement.sign(input); if (evaluation) response = await agreement.sign(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/agreement/send': case '/api/v1/agreement/send':
response = await agreement.send(input); if (evaluation) response = await agreement.send(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/event/create': case '/api/v1/event/create':
response = await event.create(input); if (evaluation) response = await event.create(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/event/sign': case '/api/v1/event/sign':
response = await event.sign(input); if (evaluation) response = await event.sign(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/event/send': case '/api/v1/event/send':
response = await event.send(input); if (evaluation) response = await event.send(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/audit/create': case '/api/v1/audit/create':
response = await audit.create(input); if (evaluation) response = await audit.create(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/audit/sign': case '/api/v1/audit/sign':
response = await audit.sign(input); if (evaluation) response = await audit.sign(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/audit/send': case '/api/v1/audit/send':
response = await audit.send(input); if (evaluation) response = await audit.send(input);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/entity/get': case '/api/v1/data/entity/get':
response = await data.entity.get(input, req.session.user_id); if (evaluation) response = await data.entity.get(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/entity/domains/get': case '/api/v1/data/entity/domains/get':
response = await data.entity.getDomains(input, req.session.user_id); if (evaluation) response = await data.entity.getDomains(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/entity/create': case '/api/v1/data/entity/create':
response = await data.entity.create(input, req.session.user_id); if (evaluation) response = await data.entity.create(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/agreement/get': case '/api/v1/data/agreement/get':
response = await data.agreement.get(input, req.session.user_id); if (evaluation) response = await data.agreement.get(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/agreement/create': case '/api/v1/data/agreement/create':
response = await data.agreement.create(input, req.session.user_id); if (evaluation) response = await data.agreement.create(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/agreement/process': case '/api/v1/data/agreement/process':
response = await data.agreement.process(input, req.session.user_id); if (evaluation) response = await data.agreement.process(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/agreement/produce': case '/api/v1/data/agreement/produce':
response = await data.agreement.produce(input, req.session.user_id); if (evaluation) response = await data.agreement.produce(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/event/get': case '/api/v1/data/event/get':
response = await data.event.get(input, req.session.user_id); if (evaluation) response = await data.event.get(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/event/create': case '/api/v1/data/event/create':
response = await data.event.create(input, req.session.user_id); if (evaluation) response = await data.event.create(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/event/process': case '/api/v1/data/event/process':
response = await data.event.process(input, req.session.user_id); if (evaluation) response = await data.event.process(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/event/produce': case '/api/v1/data/event/produce':
response = await data.event.produce(input, req.session.user_id); if (evaluation) response = await data.event.produce(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
case '/api/v1/data/audit/verify': case '/api/v1/data/audit/verify':
response = await data.audit.verify(input, req.session.user_id); if (evaluation) response = await data.audit.verify(input, req.session.user_id);
type = 'core'; type = 'core';
break; break;
}
}
if (Object.keys(config.appModules).includes('archive')) {
switch (req.url) {
case '/api/v1/audit/put':
response = await archive.put(input);
type = 'archive';
break;
case '/api/v1/audit/get':
response = await archive.get(input);
type = 'archive';
break;
} }
} if (Object.keys(config.appModules).includes('archive')) {
if (!type) { switch (req.url) {
response.error = 'Page not found'; case '/api/v1/audit/put':
res.status(404).send(response); if (evaluation) response = await archive.put(input);
type = 'archive';
break;
case '/api/v1/audit/get':
if (evaluation) response = await archive.get(input);
type = 'archive';
break;
}
}
if (!type) {
response.error = 'Page not found';
errorCode = 404;
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
response.error = e.message; response.error = e.message;
} finally { } finally {
if (response?.message) { if (evaluation) {
req.apiMessage = response.message; if (response?.message) {
} else if (response?.data?.error) { req.apiMessage = response.message;
req.apiMessage = `ERROR: ${response.data.error}`; } else if (response?.data?.error) {
req.apiMessage = `ERROR: ${response.data.error}`;
} else {
req.apiMessage = `ERROR: unknown error`;
}
if (response?.data)
response = response.data;
res.status(response?.error ? errorCode : 200).json(response);
} else { } else {
req.apiMessage = `ERROR: unknown error`; response = {
data: {
decision: false
},
message: 'Authorization denied'
}
req.apiMessage = response.message;
res.status(200).json(response.data);
} }
if (response?.data)
response = response.data;
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true); await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true);
res.status(response?.error ? 400 : 200).json(response);
} }
} }

63
src/modules/pep/cerbos.js Normal file
View File

@@ -0,0 +1,63 @@
import { getConfig } from "../../common/config.js";
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
export async function init() {
const config = getConfig();
// No initialization required
}
export async function check(req) {
// Ref:
// ----
// curl -X POST http://localhost:3592/api/check/resources \
// -H "Content-Type: application/json" \
// -d '{
// "requestId": "test-check-1",
// "principal": {
// "id": "user123",
// "roles": ["user"]
// },
// "resources": [
// {
// "resource": {
// "kind": "privateData",
// "id": "record001",
// "attr": {}
// },
// "actions": ["read"]
// }
// ]
// }'
const r = {
requestId: uuidv4(),
principal: {
id: req.subject.id,
roles: [req.subject.type]
},
resources: [
{
resource: {
kind: req.resource.type,
id: req.resource.id,
attr: {}
},
actions: [req.action.name]
}
]
}
if (req.resource.properties.ownerID) {
r.resources[0].resource.attr = { owner: req.resource.properties.ownerID }
}
const config = getConfig();
const result = await axios.post(
`${config.pdpUrl}/api/check/resources`,
r,
)
console.log(`Auth check: ${JSON.stringify(result.data)}`);
if (result.data?.results[0]?.actions[req.action.name] === 'EFFECT_ALLOW') {
return true;
}
return false;
}

35
src/modules/pep/index.js Normal file
View File

@@ -0,0 +1,35 @@
import { getConfig } from "../../common/config.js";
let evaluator;
export async function loadPep(app) {
const config = getConfig();
if (config.pdpType) {
const pepModulePath = `./${config.pdpType}.js`;
const { init, check } = await import(pepModulePath);
await init();
// // Follow AuthZEN base format
// // {
// // subject: {
// // type: "user",
// // id: "1abc",
// // },
// // action: {
// // name: "read"
// // },
// // resource: {
// // type: "data",
// // id: "2def",
// // properties: {
// // ownerID: "my@me.com"
// // }
// // }
// // }
evaluator = check;
}
}
export async function evaluate(req) {
return await evaluator(req);
}

9663
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,31 +20,32 @@
"license": "SSPLv1", "license": "SSPLv1",
"dependencies": { "dependencies": {
"@jlinc/core": "file:./packages/core", "@jlinc/core": "file:./packages/core",
"axios": "^1.10.0", "axios": "^1.13.1",
"body-parser": "^1.20.3", "body-parser": "^2.2.0",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^5.1.0",
"express-session": "^1.18.1", "express-session": "^1.18.2",
"marked": "^16.1.1", "marked": "^16.4.1",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-openidconnect": "^0.1.2", "passport-openidconnect": "^0.1.2",
"pg": "^8.13.1", "pg": "^8.16.3",
"safe-stable-stringify": "^2.5.0", "safe-stable-stringify": "^2.5.0",
"sodium-native": "^5.0.6", "sodium-native": "^5.0.9",
"swagger-ui-express": "^5.0.1" "swagger-ui-express": "^5.0.1",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.25.9", "@babel/eslint-parser": "^7.28.5",
"@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/plugin-syntax-import-assertions": "^7.27.1",
"@eslint/js": "^9.17.0", "@eslint/js": "^9.39.1",
"eslint": "^9.17.0", "eslint": "^9.39.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^10.1.8",
"globals": "^15.14.0", "globals": "^16.5.0",
"jest": "^29.7.0", "jest": "^30.2.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.10",
"prettier": "^3.4.2" "prettier": "^3.6.2"
} }
} }