From 2e9c603ab8860d5a67eb3125dded90b7df6749a1 Mon Sep 17 00:00:00 2001 From: "D. Berge" Date: Thu, 24 Jul 2025 18:21:02 +0200 Subject: [PATCH] Add @dougal/organisations NodeJS module. Abstracts the concept of Organisations in the new permissions model. --- .../@dougal/organisations/Organisation.js | 75 ++++++ .../@dougal/organisations/Organisations.js | 225 ++++++++++++++++++ lib/modules/@dougal/organisations/index.js | 5 + .../@dougal/organisations/package.json | 12 + 4 files changed, 317 insertions(+) create mode 100644 lib/modules/@dougal/organisations/Organisation.js create mode 100644 lib/modules/@dougal/organisations/Organisations.js create mode 100644 lib/modules/@dougal/organisations/index.js create mode 100644 lib/modules/@dougal/organisations/package.json diff --git a/lib/modules/@dougal/organisations/Organisation.js b/lib/modules/@dougal/organisations/Organisation.js new file mode 100644 index 0000000..6a5be32 --- /dev/null +++ b/lib/modules/@dougal/organisations/Organisation.js @@ -0,0 +1,75 @@ + +class Organisation { + + constructor (data) { + + this.read = !!data?.read; + this.write = !!data?.write; + this.edit = !!data?.edit; + + this.other = {}; + + return new Proxy(this, { + get (target, prop) { + if (prop in target) { + return target[prop] + } else { + return target.other[prop]; + } + }, + + set (target, prop, value) { + const oldValue = target[prop] !== undefined ? target[prop] : target.other[prop]; + const newValue = Boolean(value); + + if (["read", "write", "edit"].includes(prop)) { + target[prop] = newValue; + } else { + target.other[prop] = newValue; + } + + return true; + } + }); + } + + toJSON () { + return { + read: this.read, + write: this.write, + edit: this.edit, + ...this.other + } + } + + toString (replacer, space) { + return JSON.stringify(this.toJSON(), replacer, space); + } + + /** Limit the operations to only those allowed by `other` + */ + filter (other) { + const filteredOrganisation = new Organisation(); + + filteredOrganisation.read = this.read && other.read; + filteredOrganisation.write = this.write && other.write; + filteredOrganisation.edit = this.edit && other.edit; + + return filteredOrganisation; + } + + intersect (other) { + return this.filter(other); + } + +} + + +if (typeof module !== 'undefined' && module.exports) { + module.exports = Organisation; // CJS export +} + +// ESM export +if (typeof exports !== 'undefined' && !exports.default) { + exports.default = Organisation; // ESM export +} diff --git a/lib/modules/@dougal/organisations/Organisations.js b/lib/modules/@dougal/organisations/Organisations.js new file mode 100644 index 0000000..8d86a7f --- /dev/null +++ b/lib/modules/@dougal/organisations/Organisations.js @@ -0,0 +1,225 @@ +const Organisation = require('./Organisation'); + +class Organisations { + + #values = {} + + #overlord + + static entries (orgs) { + return orgs.names().map(name => [name, orgs.get(name)]); + } + + constructor (data, overlord) { + if (data instanceof Organisations) { + for (const [name, value] of Organisations.entries(data)) { + this.set(name, new Organisation(value)); + } + } else if (data instanceof Object) { + for (const [name, value] of Object.entries(data)) { + this.set(name, new Organisation(value)); + } + } else if (data instanceof String) { + this.set(data, new Organisation()); + } else if (typeof data !== "undefined") { + throw new Error("Invalid constructor argument"); + } + + if (overlord) { + this.#overlord = overlord; + } + } + + get values () { + return this.#values; + } + + get length () { + return this.names().length; + } + + get overlord () { + return this.#overlord; + } + + set overlord (v) { + this.#overlord = new Organisations(v); + } + + /** Get the operations for `name` + */ + get (name) { + const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name; + return this.values[key]; + } + + /** Set the operations for `name` to `value` + * + * If we have an overlord, ensure we cannot: + * + * 1. Add new organisations which the overlord + * is not a member of + * 2. Access operations that the overlord is not + * allowed to access + */ + set (name, value) { + name = String(name).trim(); + const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name; + const org = new Organisation(value); + + if (this.overlord) { + const parent = this.overlord.get(key) ?? this.overlord.get("*"); + if (parent) { + this.values[key] = parent.filter(org); + } + } else { + this.values[key] = new Organisation(value); + } + + return this; + } + + /** Enable the operation `op` in all organisations + */ + enableOperation (op) { + if (this.overlord) { + Object.keys(this.#values) + .filter( key => (this.overlord.get(key) ?? this.overlord.get("*"))?.[op] ) + .forEach( key => this.#values[key][op] = true ); + } else { + Object.values(this.#values).forEach( org => org[op] = true ); + } + + return this; + } + + /** Disable the operation `op` in all organisations + */ + disableOperation (op) { + Object.values(this.#values).forEach( org => org[op] = false ); + + return this; + } + + /** Create a new organisation object limited by the caller's rights + * + * The spawned Organisations instance will have the same organisations + * and rights as the caller minus the applied `mask`. With the default + * mask, the spawned object will inherit all rights except for `edit` + * rights. + * + * The "*" organisation must be explicitly assigned. It is not inherited. + */ + spawn (mask = {read: true, write: true, edit: false}) { + + const parent = new Organisations(); + const wildcard = this.get("*").edit; // If true, we can spawn everywhere + + this.entries().forEach( ([k, v]) => { + // if (k != "*") { // This organisation is not inherited + if (v.edit || wildcard) { // We have the right to spawn in this organisation + const o = new Organisation({ + read: v.read && mask.read, + write: v.write && mask.write, + edit: v.edit && mask.edit + }); + parent.set(k, o); + } + // } + }); + + return new Organisations({}, parent); + } + + remove (name) { + const key = Object.keys(this.values).find( k => k.toLowerCase() == name.toLowerCase() ) ?? name; + delete this.values[key]; + } + + /** Return the list of organisation names + */ + names () { + return Object.keys(this.values); + } + + /** Same as this.get(name) + */ + value (name) { + return this.values[name]; + } + + /** Same as Object.prototype.entries + */ + entries () { + return this.names().map( name => [ name, this.value(name) ] ); + } + + /** Return true if the named organisation is present + */ + has (name) { + return Boolean(this.value(name)); + } + + /** Return only those of our organisations + * and operations present in `other` + */ + filter (other) { + const filteredOrganisations = new Organisations(); + + const wildcard = other.value("*"); + + for (const [name, org] of this.entries()) { + const ownOrg = other.value(name) ?? wildcard; + if (ownOrg) { + filteredOrganisations.set(name, org.filter(ownOrg)) + } + } + + return filteredOrganisations; + } + + /** Return only those organisations + * that have access to the required + * operation + */ + accessToOperation (op) { + const filteredOrganisations = new Organisations(); + + for (const [name, org] of this.entries()) { + if (org[op]) { + filteredOrganisations.set(name, org); + } + } + + return filteredOrganisations; + } + + toJSON () { + const obj = {}; + for (const key in this.values) { + obj[key] = this.values[key].toJSON(); + } + return obj; + } + + toString (replacer, space) { + return JSON.stringify(this.toJSON(), replacer, space); + } + + *[Symbol.iterator] () { + for (const [name, operations] of this.entries()) { + yield {name, operations}; + } + } + +} + + +if (typeof module !== 'undefined' && module.exports) { + module.exports = Organisations; // CJS export +} + +// ESM export +if (typeof exports !== 'undefined' && !exports.default) { + exports.default = Organisations; // ESM export +} diff --git a/lib/modules/@dougal/organisations/index.js b/lib/modules/@dougal/organisations/index.js new file mode 100644 index 0000000..733c5c2 --- /dev/null +++ b/lib/modules/@dougal/organisations/index.js @@ -0,0 +1,5 @@ + +module.exports = { + Organisation: require('./Organisation'), + Organisations: require('./Organisations') +} diff --git a/lib/modules/@dougal/organisations/package.json b/lib/modules/@dougal/organisations/package.json new file mode 100644 index 0000000..d5e262e --- /dev/null +++ b/lib/modules/@dougal/organisations/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dougal/organisations", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +}