Implement authentication and access control

This commit is contained in:
Zaid Abdulla committed 2018-12-26 00:22:07 -08:00
1 parent e1bdcda191
commit 327cb78f1a
27 files changed
+600 -167

No files matched your search

+10 -4
View File
@@ -4,11 +4,15 @@ import m from "mithril";
import layout from "./layout"; import layout from "./layout";
import * as store from "./store"; import * as store from "./store";
import * as loginPage from "./login-page";
import * as overviewPage from "./overview-page"; import * as overviewPage from "./overview-page";
import * as devicesPage from "./devices-page"; import * as devicesPage from "./devices-page";
import * as devicePage from "./device-page"; import * as devicePage from "./device-page";
import * as errorPage from "./error-page"; import * as errorPage from "./error-page";
import * as faultsPage from "./faults-page"; import * as faultsPage from "./faults-page";
import Authorizer from "../common/authorizer";
window.authorizer = new Authorizer(window.permissionSets);
let state; let state;
@@ -29,17 +33,18 @@ function pagify(pageName, page) {
const component = { const component = {
render: () => { render: () => {
lastRenderTimestamp = Date.now(); lastRenderTimestamp = Date.now();
let c; let p;
if (state && state.error) c = m(errorPage.component, state); if (state && state.error) p = m(errorPage.component, state);
else c = m(layout, { page: pageName }, m(page.component, state)); else p = m(page.component, state);
fulfill(); fulfill();
return c; return m(layout, { page: pageName }, p);
} }
}; };
component.onmatch = args => { component.onmatch = args => {
pageVisitTimestamp = Date.now(); pageVisitTimestamp = Date.now();
if (!page.init) { if (!page.init) {
state = null;
fulfill(); fulfill();
return; return;
} }
@@ -64,6 +69,7 @@ function pagify(pageName, page) {
} }
m.route(document.body, "/overview", { m.route(document.body, "/overview", {
"/login": pagify("login", loginPage),
"/overview": pagify("overview", overviewPage), "/overview": pagify("overview", overviewPage),
"/devices": pagify("devices", devicesPage), "/devices": pagify("devices", devicesPage),
"/devices/:id": pagify("devices", devicePage), "/devices/:id": pagify("devices", devicePage),
+3 -2
View File
@@ -50,11 +50,12 @@ const component = {
title: "Delete device", title: "Delete device",
onclick: () => { onclick: () => {
if (!confirm("Deleting this device. Are you sure?")) return; if (!confirm("Deleting this device. Are you sure?")) return;
const deviceId = device["DeviceID.ID"].value[0];
store store
.deleteResource("devices", device["DeviceID.ID"].value[0]) .deleteResource("devices", deviceId)
.then(() => { .then(() => {
notifications.push("success", "Device deleted"); notifications.push("success", `${deviceId}: Device deleted`);
m.route.set("/devices"); m.route.set("/devices");
}) })
.catch(err => { .catch(err => {
+27 -17
View File
@@ -31,23 +31,33 @@ const component = {
}; };
taskQueue taskQueue
.commit([task], (deviceId, connectionRequestStatus, tasks2) => { .commit(
for (let t of tasks2) [task],
if (t.status === "stale") taskQueue.deleteTask(t); (deviceId, err, connectionRequestStatus, tasks2) => {
if (connectionRequestStatus !== "OK") if (err)
notifications.push( return notifications.push(
"error", "error",
`${deviceId}: ${connectionRequestStatus}` `${deviceId}: ${err.message}`
); );
else if (tasks2[0].status === "stale")
notifications.push( for (let t of tasks2)
"error", if (t.status === "stale") taskQueue.deleteTask(t);
`${deviceId}: No contact from device`
); if (connectionRequestStatus !== "OK")
else if (tasks2[0].status === "fault") notifications.push(
notifications.push("error", `${deviceId}: Refresh faulted`); "error",
else notifications.push("success", `${deviceId}: Summoned`); `${deviceId}: ${connectionRequestStatus}`
}) );
else if (tasks2[0].status === "stale")
notifications.push(
"error",
`${deviceId}: No contact from device`
);
else if (tasks2[0].status === "fault")
notifications.push("error", `${deviceId}: Refresh faulted`);
else notifications.push("success", `${deviceId}: Summoned`);
}
)
.then(() => { .then(() => {
e.target.disabled = false; e.target.disabled = false;
store.fulfill(0, Date.now()); store.fulfill(0, Date.now());
+14 -6
View File
@@ -29,14 +29,18 @@ const component = {
{ {
onclick: e => { onclick: e => {
e.target.disabled = true; e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0];
store store
.updateTags(device["DeviceID.ID"].value[0], { [tag]: false }) .updateTags(deviceId, { [tag]: false })
.then(() => { .then(() => {
e.target.disabled = false; e.target.disabled = false;
notifications.push("success", `Tag '${tag}' unassigned`); notifications.push("success", `${deviceId}: Tags updated`);
store.fulfill(0, Date.now()); store.fulfill(0, Date.now());
}) })
.catch(err => notifications.push("error", err.message)); .catch(err => {
e.target.disabled = false;
notifications.push("error", `${deviceId}: ${err.message}`);
});
} }
}, },
"✕" "✕"
@@ -50,6 +54,7 @@ const component = {
{ {
onclick: e => { onclick: e => {
e.target.disabled = true; e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0];
const tag = prompt(`Enter tag to assign to device:`); const tag = prompt(`Enter tag to assign to device:`);
if (!tag) { if (!tag) {
e.target.disabled = false; e.target.disabled = false;
@@ -57,13 +62,16 @@ const component = {
} }
store store
.updateTags(device["DeviceID.ID"].value[0], { [tag]: true }) .updateTags(deviceId, { [tag]: true })
.then(() => { .then(() => {
e.target.disabled = false; e.target.disabled = false;
notifications.push("success", `Tag '${tag}' assigned`); notifications.push("success", `${deviceId}: Tags updated`);
store.fulfill(0, Date.now()); store.fulfill(0, Date.now());
}) })
.catch(err => notifications.push("error", err.message)); .catch(err => {
e.target.disabled = false;
notifications.push("error", `${deviceId}: ${err.message}`);
});
} }
}, },
"🞢" "🞢"
+10
View File
@@ -229,11 +229,17 @@ i {
} }
} }
} }
& > .logo { & > .logo {
margin: 10px; margin: 10px;
display: inline; display: inline;
height: 56px; height: 56px;
} }
& > .user-menu {
float: right;
margin: 5px;
}
} }
#content { #content {
@@ -308,6 +314,10 @@ h3 {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
p.error {
color: #a00;
}
/* Remove button padding in FF */ /* Remove button padding in FF */
input::-moz-focus-inner { input::-moz-focus-inner {
border: 0; border: 0;
+8 -5
View File
@@ -8,11 +8,14 @@ import * as store from "./store";
import * as components from "./components"; import * as components from "./components";
function init(args) { function init(args) {
return new Promise(resolve => { if (!window.authorizer.hasAccess("devices", 2))
resolve({ return Promise.reject(
deviceId: args.id, new Error("You are not authorized to view this page")
deviceFilter: new Filter(["=", "DeviceID.ID", args.id]) );
});
return Promise.resolve({
deviceId: args.id,
deviceFilter: new Filter(["=", "DeviceID.ID", args.id])
}); });
} }
+49 -35
View File
@@ -12,7 +12,10 @@ import * as taskQueue from "./task-queue";
import * as notifications from "./notifications"; import * as notifications from "./notifications";
function init(args) { function init(args) {
return new Promise(resolve => { return new Promise((resolve, reject) => {
if (!window.authorizer.hasAccess("devices", 2))
return reject(new Error("You are not authorized to view this page"));
let filter = new Filter(args.filter); let filter = new Filter(args.filter);
let indexParameters = Object.values(config.get("ui.index")).map(p => let indexParameters = Object.values(config.get("ui.index")).map(p =>
Object.assign({}, p, { Object.assign({}, p, {
@@ -177,14 +180,21 @@ function renderActions(selected) {
const ids = Array.from(selected); const ids = Array.from(selected);
if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return; if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return;
Promise.all(ids.map(id => store.deleteResource("devices", id))) let counter = 1;
.then(() => { for (let id of ids) {
notifications.push("success", `${ids.size} devices deleted`); ++counter;
store.fulfill(0, Date.now()); store
}) .deleteResource("devices", id)
.catch(err => { .then(() => {
notifications.push("error", err.message); notifications.push("success", `${id}: Deleted`);
}); if (--counter === 0) store.fulfill(0, Date.now());
})
.catch(err => {
notifications.push("error", `${id}: ${err.message}`);
if (--counter === 0) store.fulfill(0, Date.now());
});
}
if (--counter === 0) store.fulfill(0, Date.now());
} }
}, },
"Delete" "Delete"
@@ -202,19 +212,21 @@ function renderActions(selected) {
const tag = prompt(`Enter tag to assign to ${ids.length} devices:`); const tag = prompt(`Enter tag to assign to ${ids.length} devices:`);
if (!tag) return; if (!tag) return;
Promise.all( let counter = 1;
ids.map(id => { for (let id of ids) {
store.updateTags(id, { [tag]: true }); ++counter;
}) store
) .updateTags(id, { [tag]: true })
.then(() => { .then(() => {
notifications.push( notifications.push("success", `${id}: Tags updated`);
"success", if (--counter === 0) store.fulfill(0, Date.now());
`Tag '${tag}' assigned to ${ids.length} devices` })
); .catch(err => {
store.fulfill(0, Date.now()); notifications.push("error", `${id}: ${err.message}`);
}) if (--counter === 0) store.fulfill(0, Date.now());
.catch(err => notifications.push("error", err.message)); });
}
if (--counter === 0) store.fulfill(0, Date.now());
} }
}, },
"Tag" "Tag"
@@ -234,19 +246,21 @@ function renderActions(selected) {
); );
if (!tag) return; if (!tag) return;
Promise.all( let counter = 1;
ids.map(id => { for (let id of ids) {
store.updateTags(id, { [tag]: false }); ++counter;
}) store
) .updateTags(id, { [tag]: false })
.then(() => { .then(() => {
notifications.push( notifications.push("success", `${id}: Tags updated`);
"success", if (--counter === 0) store.fulfill(0, Date.now());
`Tag '${tag}' unassigned from ${ids.length} devices` })
); .catch(err => {
store.fulfill(0, Date.now()); notifications.push("error", `${id}: ${err.message}`);
}) if (--counter === 0) store.fulfill(0, Date.now());
.catch(err => notifications.push("error", err.message)); });
}
if (--counter === 0) store.fulfill(0, Date.now());
} }
}, },
"Untag" "Untag"
+30 -25
View File
@@ -243,41 +243,46 @@ const component = {
t => t.status === "queued" t => t.status === "queued"
); );
taskQueue taskQueue
.commit(tasks, (deviceId, connectionRequestStatus, tasks2) => { .commit(
if (connectionRequestStatus !== "OK") { tasks,
notifications.push( (deviceId, err, connectionRequestStatus, tasks2) => {
"error", if (err)
`${deviceId}: ${connectionRequestStatus}` return notifications.push(
);
return;
}
for (let t of tasks2)
if (t.status === "stale") {
notifications.push(
"error", "error",
`${deviceId}: No contact from device` `${deviceId}: ${err.message}`
); );
return;
} else if (t.status === "fault") { if (connectionRequestStatus !== "OK") {
notifications.push( notifications.push(
"error", "error",
`${deviceId}: Task(s) faulted` `${deviceId}: ${connectionRequestStatus}`
); );
return; return;
} }
notifications.push( for (let t of tasks2)
"success", if (t.status === "stale") {
`${deviceId}: Task(s) committed` notifications.push(
); "error",
}) `${deviceId}: No contact from device`
);
return;
} else if (t.status === "fault") {
notifications.push(
"error",
`${deviceId}: Task(s) faulted`
);
return;
}
notifications.push(
"success",
`${deviceId}: Task(s) committed`
);
}
)
.then(() => { .then(() => {
store.fulfill(0, Date.now()); store.fulfill(0, Date.now());
})
.catch(err => {
notifications.push("error", err.message);
throw err;
}); });
} }
}, },
+3 -1
View File
@@ -1,9 +1,11 @@
"use strict"; "use strict";
import m from "mithril";
const component = { const component = {
view: function(vnode) { view: function(vnode) {
document.title = "Error! - GenieACS"; document.title = "Error! - GenieACS";
return vnode.attrs.error; return m("p.error", vnode.attrs.error);
} }
}; };
+7 -4
View File
@@ -9,10 +9,13 @@ import * as store from "./store";
import * as notifications from "./notifications"; import * as notifications from "./notifications";
function init(args) { function init(args) {
return new Promise(resolve => { if (!window.authorizer.hasAccess("devices", 2))
let filter = new Filter(args.filter); return Promise.reject(
resolve({ filter: filter }); new Error("You are not authorized to view this page")
}); );
let filter = new Filter(args.filter);
return Promise.resolve({ filter: filter });
} }
function renderTable(faultsResponse, total, selected, showMoreCallback) { function renderTable(faultsResponse, total, selected, showMoreCallback) {
+2
View File
@@ -4,12 +4,14 @@ import m from "mithril";
import menu from "./menu"; import menu from "./menu";
import drawerComponent from "./drawer-component"; import drawerComponent from "./drawer-component";
import userMenu from "./user-menu";
const layout = { const layout = {
view: vnode => { view: vnode => {
return [ return [
m("#header", [ m("#header", [
m("img.logo", { src: "logo.svg" }), m("img.logo", { src: "logo.svg" }),
m(userMenu),
m(menu, { page: vnode.attrs.page }), m(menu, { page: vnode.attrs.page }),
m(drawerComponent) m(drawerComponent)
]), ]),
+72
View File
@@ -0,0 +1,72 @@
"use strict";
import m from "mithril";
import * as store from "./store";
import * as notifications from "./notifications";
function init(args) {
return Promise.resolve(args);
}
const component = {
view: vnode => {
if (window.username) m.route.set(vnode.attrs.continue || "/");
document.title = "Login - GenieACS";
return [
m("h1", "Log in"),
m(
"form",
m(
"p",
m("label", { for: "username" }, "Username"),
m("input", {
name: "username",
type: "text",
value: vnode.state.username,
oncreate: vnode2 => {
vnode2.dom.focus();
},
oninput: m.withAttr("value", v => (vnode.state.username = v))
})
),
m(
"p",
m("label", { for: "password" }, "Password"),
m("input", {
name: "password",
type: "password",
value: vnode.state.password,
oninput: m.withAttr("value", v => (vnode.state.password = v))
})
),
m(
"p",
m(
"button.primary",
{
type: "submit",
onclick: e => {
e.target.disabled = true;
store
.logIn(vnode.state.username, vnode.state.password)
.then(() => {
location.reload();
})
.catch(err => {
notifications.push("error", err.message);
e.target.disabled = false;
});
return false;
}
},
"Login"
)
)
)
];
}
};
export { init, component };
+16 -7
View File
@@ -6,26 +6,35 @@ const menu = {
view: vnode => { view: vnode => {
const active = { [vnode.attrs.page]: "active" }; const active = { [vnode.attrs.page]: "active" };
return m( let tabs = [];
"nav", if (window.authorizer.hasAccess("devices", 1))
m("ul", [ tabs.push(
m( m(
"li", "li",
{ class: active["overview"] }, { class: active["overview"] },
m("a", { href: "#!/overview" }, "Overview") m("a", { href: "#!/overview" }, "Overview")
), )
);
if (window.authorizer.hasAccess("devices", 2))
tabs.push(
m( m(
"li", "li",
{ class: active["devices"] }, { class: active["devices"] },
m("a", { href: "#!/devices" }, "Devices") m("a", { href: "#!/devices" }, "Devices")
), )
);
if (window.authorizer.hasAccess("faults", 2))
tabs.push(
m( m(
"li", "li",
{ class: active["faults"] }, { class: active["faults"] },
m("a", { href: "#!/faults" }, "Faults") m("a", { href: "#!/faults" }, "Faults")
) )
]) );
);
return m("nav", m("ul", tabs));
} }
}; };
+6 -3
View File
@@ -24,9 +24,12 @@ function queryCharts(charts) {
} }
const init = function() { const init = function() {
return new Promise(resolve => { if (!window.authorizer.hasAccess("devices", 1))
resolve({ charts: queryCharts(CHARTS) }); return Promise.reject(
}); new Error("You are not authorized to view this page")
);
return Promise.resolve({ charts: queryCharts(CHARTS) });
}; };
const component = { const component = {
+44 -36
View File
@@ -323,43 +323,33 @@ function getTimestamp() {
return fulfillTimestamp; return fulfillTimestamp;
} }
function postTasks(tasks, callback) { function postTasks(deviceId, tasks) {
for (let t of tasks) {
t.status = "pending";
t.device = deviceId;
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let devices = {}; m
for (let t of tasks) { .request({
t.status = "pending"; method: "POST",
devices[t.device] = devices[t.device] || []; url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`,
devices[t.device].push(t); data: tasks,
} extract: xhr => {
if (xhr.status !== 200) throw new Error(xhr.response);
let promises = []; const connectionRequestStatus = xhr.getResponseHeader(
for (let [deviceId, tasks2] of Object.entries(devices)) "Connection-Request"
promises.push( );
m.request({ let st = JSON.parse(xhr.response);
method: "POST", for (let [i, t] of st.entries()) {
url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, tasks[i]._id = t._id;
data: tasks2, tasks[i].status = t.status;
extract: xhr => { tasks[i].fault = t.fault;
const connectionRequestStatus = xhr.getResponseHeader(
"Connection-Request"
);
let st = JSON.parse(xhr.response);
for (let [i, t] of st.entries()) {
tasks2[i]._id = t._id;
tasks2[i].status = t.status;
tasks2[i].fault = t.fault;
}
if (callback) callback(deviceId, connectionRequestStatus, tasks2);
} }
}) resolve(connectionRequestStatus);
); }
})
Promise.all(promises) .catch(reject);
.then(() => resolve(tasks))
.catch(err => {
callback = null;
reject(err);
});
}); });
} }
@@ -394,6 +384,22 @@ function evaluateExpression(exp, device) {
return exps.get(device); return exps.get(device);
} }
function logIn(username, password) {
return m.request({
method: "POST",
url: "/login",
background: true,
data: { username, password }
});
}
function logOut() {
return m.request({
method: "POST",
url: "/logout"
});
}
export { export {
count, count,
fetch, fetch,
@@ -403,5 +409,7 @@ export {
postTasks, postTasks,
updateTags, updateTags,
deleteResource, deleteResource,
evaluateExpression evaluateExpression,
logIn,
logOut
}; };
+23 -7
View File
@@ -37,16 +37,32 @@ function stageSpv(task) {
} }
function commit(tasks, callback) { function commit(tasks, callback) {
let devices = {};
for (let t of tasks) { for (let t of tasks) {
t.status = "pending"; devices[t.device] = devices[t.device] || [];
queue.add(t); devices[t.device].push(t);
queueTask(t);
} }
return store.postTasks(tasks, (deviceId, connectionRequestStatus, tasks2) => { return new Promise(resolve => {
for (let t of tasks2) let counter = 1;
if (t.status === "pending") t.status = "stale"; for (let [deviceId, tasks2] of Object.entries(devices))
else if (t.status === "done") queue.delete(t); store
if (callback) callback(deviceId, connectionRequestStatus, tasks2); .postTasks(deviceId, tasks)
.then(connectionRequestStatus => {
for (let t of tasks2)
if (t.status === "pending") t.status = "stale";
else if (t.status === "done") queue.delete(t);
callback(deviceId, null, connectionRequestStatus, tasks2);
if (--counter === 0) resolve();
})
.catch(err => {
for (let t of tasks2) t.status = "stale";
callback(deviceId, err, null, tasks2);
if (--counter === 0) resolve();
});
if (--counter === 0) resolve();
}); });
} }
+49
View File
@@ -0,0 +1,49 @@
"use strict";
import m from "mithril";
import * as store from "./store";
import * as notifications from "./notifications";
const userMenu = {
view: () => {
if (window.username)
return m(
"div.user-menu",
window.username,
m(
"button",
{
onclick: e => {
e.target.disabled = true;
store
.logOut()
.then(() => {
location.hash = "";
location.reload();
})
.catch(err => {
e.target.disabled = false;
notifications.push("error", err.message);
});
return false;
}
},
"Log out"
)
);
else
return m(
"div.user-menu",
m(
"a",
{
href: "#!/login?" + m.buildQueryString({ continue: m.route.get() })
},
"Log in"
)
);
}
};
export default userMenu;
+78
View File
@@ -0,0 +1,78 @@
"use strict";
const validators = {
pass: require("./validators/pass"),
test: require("./validators/test")
};
class Authorizer {
constructor(permissionSets) {
this.permissionSets = permissionSets;
this.validtorCache = new WeakMap();
this.hasAccessCache = {};
}
hasAccess(resourceType, access) {
const cacheKey = `${resourceType}-${access}`;
if (cacheKey in this.hasAccessCache)
return this.hasAccessCache[cacheKey];
let has = false;
for (const permissionSet of this.permissionSets) {
for (const perm of permissionSet) {
if (perm[resourceType]) {
if (perm[resourceType].access >= access) {
has = true;
break;
}
}
}
}
this.hasAccessCache[cacheKey] = has;
return has;
}
getValidator(resourceType, resource) {
if (this.validtorCache.has(resource))
return this.validtorCache.get(resource);
let funcs = {};
for (let permissionSet of this.permissionSets)
for (let perm of permissionSet)
if (perm[resourceType]) {
if (perm[resourceType].access >= 3) {
if (perm[resourceType].validate)
for (let [k, v] of Object.entries(perm[resourceType].validate))
funcs[k] = v;
} else {
funcs = {};
}
}
const validator = (mutationType, mutation, any) => {
let valid = false;
for (let [k, v] of Object.entries(funcs))
if (v) {
let res = validators[k](
resourceType,
resource,
mutationType,
mutation,
any
);
if (res > 0) valid = true;
else if (res < 0) return false;
}
return valid;
};
this.validtorCache.set(resource, validator);
return validator;
}
}
module.exports = Authorizer;
+5 -3
View File
@@ -17,9 +17,10 @@ function* permute(arr) {
class Filter { class Filter {
constructor(str) { constructor(str) {
if (Array.isArray(str)) this.ast = str; if (!str) this.ast = null;
else if (Array.isArray(str)) this.ast = str;
else if (str instanceof Filter) this.ast = str.ast; else if (str instanceof Filter) this.ast = str.ast;
else if (!str) this.ast = null; else if (Array.isArray(str.ast) || str.ast === null) this.ast = str.ast;
else this.ast = filterParser.parse(str); else this.ast = filterParser.parse(str);
} }
@@ -33,6 +34,7 @@ class Filter {
or(fltr) { or(fltr) {
if (!fltr || !this.ast) return this; if (!fltr || !this.ast) return this;
else if (!fltr.ast) return fltr;
let f = ["OR"]; let f = ["OR"];
@@ -46,7 +48,7 @@ class Filter {
} }
and(fltr) { and(fltr) {
if (!fltr) return this; if (!fltr || !fltr.ast) return this;
else if (!this.ast) return fltr; else if (!this.ast) return fltr;
let f = ["AND"]; let f = ["AND"];
+5
View File
@@ -0,0 +1,5 @@
function pass() {
return 1;
}
module.exports = pass;
+14
View File
@@ -0,0 +1,14 @@
function test(resourceType, resource) {
if (resourceType === "devices") {
if (resource["Tags.test"] && resource["Tags.test"].value[0]) return 1;
} else if (resourceType === "presets") {
if (resource["_id"].startsWith("test-"))
// TODO check "test" tag too
return 1;
} else if (resourceType === "provisions") {
if (resource["_id"].startsWith("test-")) return 1;
}
return -1;
}
module.exports = test;
+11
View File
@@ -1,6 +1,17 @@
{ {
"server.port": 3000, "server.port": 3000,
"server.nbi": "http://127.0.0.1:7557/", "server.nbi": "http://127.0.0.1:7557/",
"server.jwtSecret": "jwtSecret",
"auth.simple.users.admin.password": "admin",
"auth.simple.users.admin.roles": "admin",
"auth.simple.users.view.password": "view",
"auth.simple.users.view.roles": "view",
"permissions.admin.1.devices.access": 3,
"permissions.admin.1.devices.validate.pass": true,
"permissions.admin.1.faults.access": 3,
"permissions.admin.1.faults.validate.pass": true,
"permissions.view.1.devices.access": 2,
"permissions.view.1.faults.access": 2,
"ui.pageSize": 10, "ui.pageSize": 10,
"ui.filters.1.label": "Serial number", "ui.filters.1.label": "Serial number",
"ui.filters.1.parameter": "DeviceID.SerialNumber", "ui.filters.1.parameter": "DeviceID.SerialNumber",
+3 -1
View File
@@ -4,16 +4,18 @@
"description": "", "description": "",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint --fix common/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js server/*.js client/*.js client/components/*.js client/css/*.css", "lint": "eslint --fix common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js;prettier --write common/*.js common/validators/*.js server/*.js client/*.js client/components/*.js client/css/*.css",
"build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css", "build": "npm run lint;webpack;postcss -c postcss.config.js client/css/app.css -o public/app.css",
"start": "node server/server" "start": "node server/server"
}, },
"author": "Zaid Abdulla <zaid@genieacs.com>", "author": "Zaid Abdulla <zaid@genieacs.com>",
"dependencies": { "dependencies": {
"jsonwebtoken": "^8.2.1",
"koa": "^2.5.0", "koa": "^2.5.0",
"koa-body": "^2.5.0", "koa-body": "^2.5.0",
"koa-bodyparser": "^4.2.0", "koa-bodyparser": "^4.2.0",
"koa-compress": "^2.0.0", "koa-compress": "^2.0.0",
"koa-jwt": "^3.3.1",
"koa-router": "^7.4.0", "koa-router": "^7.4.0",
"koa-static": "^4.0.2", "koa-static": "^4.0.2",
"mithril": "^1.1.6", "mithril": "^1.1.6",
+2 -2
View File
@@ -80,7 +80,7 @@ function filterToQuery(filter, negate = false, res = {}) {
function count(resource, filter, limit) { function count(resource, filter, limit) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let qs = {}; let qs = {};
if (filter) { if (filter && filter.ast) {
filter = filter.evaluateExpressions(); filter = filter.evaluateExpressions();
let ast = unpackTimestamps(filter.ast); let ast = unpackTimestamps(filter.ast);
@@ -117,7 +117,7 @@ function query(resource, filter, limit, skip, projection, callback) {
let ret; let ret;
if (!callback) ret = []; if (!callback) ret = [];
let qs = {}; let qs = {};
if (filter) { if (filter && filter.ast) {
filter = filter.evaluateExpressions(); filter = filter.evaluateExpressions();
let ast = unpackTimestamps(filter.ast); let ast = unpackTimestamps(filter.ast);
+42 -9
View File
@@ -18,11 +18,13 @@ const resources = {
}; };
for (let [resource, update] of Object.entries(resources)) { for (let [resource, update] of Object.entries(resources)) {
router.head(`/${resource}`, async ctx => { router.head(`/${resource}`, async (ctx, next) => {
let filter, limit; let filter, limit;
if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter);
if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.limit) limit = +ctx.request.query.limit;
if (!ctx.state.authorizer.hasAccess(resource, 1)) return next();
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
if (resource === "tasks" || resource === "faults") { if (resource === "tasks" || resource === "faults") {
let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]);
@@ -34,12 +36,14 @@ for (let [resource, update] of Object.entries(resources)) {
ctx.body = ""; ctx.body = "";
}); });
router.get(`/${resource}`, async ctx => { router.get(`/${resource}`, async (ctx, next) => {
let filter, limit, skip; let filter, limit, skip;
if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter);
if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.limit) limit = +ctx.request.query.limit;
if (ctx.request.query.skip) skip = +ctx.request.query.skip; if (ctx.request.query.skip) skip = +ctx.request.query.skip;
if (!ctx.state.authorizer.hasAccess(resource, 2)) return next();
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
if (resource === "tasks" || resource === "faults") { if (resource === "tasks" || resource === "faults") {
let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]);
@@ -57,6 +61,8 @@ for (let [resource, update] of Object.entries(resources)) {
router.head(`/${resource}/:id`, async (ctx, next) => { router.head(`/${resource}/:id`, async (ctx, next) => {
let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`);
if (!ctx.state.authorizer.hasAccess(resource, 2)) return next();
let res = await apiFunctions.query(resource, filter); let res = await apiFunctions.query(resource, filter);
if (!res.length) return next(); if (!res.length) return next();
ctx.body = ""; ctx.body = "";
@@ -64,6 +70,8 @@ for (let [resource, update] of Object.entries(resources)) {
router.get(`/${resource}/:id`, async (ctx, next) => { router.get(`/${resource}/:id`, async (ctx, next) => {
let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`); let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`);
if (!ctx.state.authorizer.hasAccess(resource, 2)) return next();
let res = await apiFunctions.query(resource, filter); let res = await apiFunctions.query(resource, filter);
if (!res.length) return next(); if (!res.length) return next();
ctx.body = res[0]; ctx.body = res[0];
@@ -71,22 +79,47 @@ for (let [resource, update] of Object.entries(resources)) {
// TODO add PUT, PATCH routes // TODO add PUT, PATCH routes
if (update) if (update)
router.delete(`/${resource}/:id`, async ctx => { router.delete(`/${resource}/:id`, async (ctx, next) => {
let found = await apiFunctions.deleteResource(resource, ctx.params.id); const authorizer = ctx.state.authorizer;
if (found) ctx.status = 200; let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`);
else ctx.status = 404; if (!authorizer.hasAccess(resource, 2)) return next();
let res = await apiFunctions.query(resource, filter);
if (!res.length) return next();
const validate = authorizer.getValidator(resource, res[0]);
if (!validate("delete")) return (ctx.status = 403);
await apiFunctions.deleteResource(resource, ctx.params.id);
ctx.body = ""; ctx.body = "";
}); });
} }
router.post("/devices/:id/tasks", async ctx => { router.post("/devices/:id/tasks", async (ctx, next) => {
const authorizer = ctx.state.authorizer;
let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`);
if (!authorizer.hasAccess("devices", 2)) return next();
let devices = await apiFunctions.query("devices", filter);
if (!devices.length) return next();
const validate = authorizer.getValidator("devices", devices[0]);
for (let t of ctx.request.body)
if (!validate("task", t)) return (ctx.status = 403);
let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body);
// TODO 404 if no such device
ctx.set("Connection-Request", res.connectionRequest); ctx.set("Connection-Request", res.connectionRequest);
ctx.body = res.tasks; ctx.body = res.tasks;
}); });
router.post("/devices/:id/tags", async ctx => { router.post("/devices/:id/tags", async (ctx, next) => {
const authorizer = ctx.state.authorizer;
let filter = new Filter(`DeviceID.ID = "${ctx.params.id}"`);
if (!authorizer.hasAccess("devices", 2)) return next();
let res = await apiFunctions.query("devices", filter);
if (!res.length) return next();
const validate = authorizer.getValidator("devices", res[0]);
if (!validate("tags", ctx.request.body)) return (ctx.status = 403);
await apiFunctions.updateTags(ctx.params.id, ctx.request.body); await apiFunctions.updateTags(ctx.params.id, ctx.request.body);
ctx.body = ""; ctx.body = "";
}); });
+7
View File
@@ -10,6 +10,13 @@ const cache = {};
function parse(name, value) { function parse(name, value) {
if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name)) if (/^ui\.overview\.charts\.[^.]+\.slices\.[^.]+\.filter$/.test(name))
return new Filter(value); return new Filter(value);
else if (/^permissions\.[^.]+\.[^.]+\.devices\.filter$/.test(name))
return new Filter(value);
else if (/^auth\.[^.]+\.users\.[^.]+\.roles$/.test(name))
return value
.split(",")
.map(x => x.trim())
.sort();
return value; return value;
} }
+60
View File
@@ -5,17 +5,73 @@ const Router = require("koa-router");
const koaStatic = require("koa-static"); const koaStatic = require("koa-static");
const koaCompress = require("koa-compress"); const koaCompress = require("koa-compress");
const koaBody = require("koa-body"); const koaBody = require("koa-body");
const koaJwt = require("koa-jwt");
const jwt = require("jsonwebtoken");
const config = require("./config"); const config = require("./config");
const api = require("./api"); const api = require("./api");
const Authorizer = require("../common/authorizer");
const koa = new Koa(); const koa = new Koa();
const router = new Router(); const router = new Router();
const JWT_SECRET = config.get("server.jwtSecret");
const JWT_COOKIE = "genieacs-ui-jwt";
function getPermissionSets(roles) {
const allPermissions = config.get("permissions");
const permissionSets = roles.map(r => Object.values(allPermissions[r] || {}));
return permissionSets;
}
koa.use(
koaJwt({
secret: JWT_SECRET,
passthrough: true,
cookie: JWT_COOKIE
})
);
koa.use(async (ctx, next) => {
if (ctx.state.user && ctx.state.user.roles)
ctx.state.authorizer = new Authorizer(
getPermissionSets(ctx.state.user.roles)
);
else ctx.state.authorizer = new Authorizer([]);
return next();
});
router.post("/login", async ctx => {
const username = ctx.request.body.username;
const password = ctx.request.body.password;
const user = config.get(`auth.simple.users.${username}`);
if (!user || user.password !== password) {
ctx.status = 400;
ctx.body = "Incorrect username or password";
return;
}
let token = jwt.sign({ username: username, roles: user.roles }, JWT_SECRET);
ctx.cookies.set(JWT_COOKIE, token);
ctx.body = JSON.stringify(token);
});
router.post("/logout", async ctx => {
ctx.cookies.set(JWT_COOKIE); // Delete cookie
ctx.body = "";
});
koa.use(koaBody()); koa.use(koaBody());
router.use("/api", api.routes(), api.allowedMethods()); router.use("/api", api.routes(), api.allowedMethods());
router.get("/", async ctx => { router.get("/", async ctx => {
let permissionSets = [];
if (ctx.state.user && ctx.state.user.roles)
permissionSets = getPermissionSets(ctx.state.user.roles);
ctx.body = ` ctx.body = `
<html> <html>
<head> <head>
@@ -26,6 +82,10 @@ router.get("/", async ctx => {
<body> <body>
<script> <script>
window.clientConfig = ${JSON.stringify(config.getClientConfig())}; window.clientConfig = ${JSON.stringify(config.getClientConfig())};
window.username = ${JSON.stringify(
ctx.state.user ? ctx.state.user.username : ""
)};
window.permissionSets = ${JSON.stringify(permissionSets)};
</script> </script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>