@eturino/claims
Version:
Claim, ClaimSet and Ability for permissions (Typescript port of https://github.com/eturino/claims)
461 lines (452 loc) • 15 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all2) => {
for (var name in all2)
__defProp(target, name, { get: all2[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ALLOWED_VERBS: () => ALLOWED_VERBS,
Ability: () => Ability,
Claim: () => Claim,
ClaimSet: () => ClaimSet,
FrozenClaimSetError: () => FrozenClaimSetError,
InvalidPatternError: () => InvalidPatternError,
InvalidVerbError: () => InvalidVerbError,
buildAbility: () => buildAbility,
buildClaim: () => buildClaim,
buildClaimSet: () => buildClaimSet,
isAllowedVerb: () => isAllowedVerb,
isValidClaimString: () => isValidClaimString
});
module.exports = __toCommonJS(index_exports);
// src/lib/claims/ability.ts
var import_key_set = require("@eturino/key-set");
// src/lib/claims/claim.ts
var import_es_toolkit = require("es-toolkit");
// src/lib/claims/rules.ts
var ALLOWED_VERBS = ["admin", "read", "delete", "create", "update", "manage"];
function isAllowedVerb(verb) {
return ALLOWED_VERBS.includes(verb);
}
// src/lib/claims/errors.ts
var InvalidVerbError = class extends Error {
constructor(verb) {
super(`the given verb '${verb}' is not one of the allowed verbs: ${JSON.stringify(ALLOWED_VERBS)}`);
this.verb = verb;
}
};
var InvalidPatternError = class extends Error {
constructor(raw) {
super(`the given raw string cannot be parsed as a claim ${JSON.stringify(raw)}`);
this.raw = raw;
}
};
var FrozenClaimSetError = class extends Error {
};
// src/lib/claims/claim.ts
var CLAIM_REGEX = /^([\w_\-]+):([\w_.\-]+\w)(\.\*)?$/;
var GLOBAL_WILDCARD_CLAIM_REGEX = /^([\w_\-]+):\*$/;
function extractFromString(s) {
const globalMatch = GLOBAL_WILDCARD_CLAIM_REGEX.exec(s);
if (globalMatch) {
const verb = globalMatch[1];
if (!isAllowedVerb(verb)) {
throw new InvalidVerbError(verb);
}
return { verb, resource: null };
}
const resourceMatch = CLAIM_REGEX.exec(s);
if (resourceMatch) {
const verb = resourceMatch[1];
if (!isAllowedVerb(verb)) {
throw new InvalidVerbError(verb);
}
const resource = resourceMatch[2];
if (resource.includes("..")) {
throw new InvalidPatternError(s);
}
return { verb, resource };
}
throw new InvalidPatternError(s);
}
function extractVerbResource(stringOrData) {
if (stringOrData instanceof Claim) {
return { verb: stringOrData.verb, resource: stringOrData.resource };
}
if ((0, import_es_toolkit.isString)(stringOrData)) {
return extractFromString(stringOrData);
}
if ((0, import_es_toolkit.isPlainObject)(stringOrData) && "verb" in stringOrData) {
return { verb: stringOrData.verb, resource: stringOrData.resource || null };
}
throw new Error(
"cannot recognise verb and resource, it is neither `verb:*` or `verb:some.resource` string or an object with `verb` and `resource`"
);
}
function partsFromResource(resource) {
if (!resource) return [];
return resource.split(".");
}
var Claim = class _Claim {
get resourceParts() {
if (!this._resourceParts) {
this._resourceParts = partsFromResource(this.resource);
}
return this._resourceParts;
}
verb;
resource;
_resourceParts = null;
constructor({ verb, resource }) {
if (!isAllowedVerb(verb)) {
throw new InvalidVerbError(verb);
}
this.verb = verb;
this.resource = resource;
}
/**
* returns a new Claim with the same data
*/
clone() {
return new _Claim({ verb: this.verb, resource: this.resource });
}
/**
* returns `verb:resource` (if global, it will return `verb:*`)
*/
toString() {
return `${this.verb}:${this.resource || "*"}`;
}
/**
* true if the given verb is the same as the claim's
* @param verb
*/
hasVerb(verb) {
return this.verb === verb;
}
/**
* true if the claim has no resource (global verb). This means that it represents all resources for this verb
*/
isGlobal() {
return !this.resource;
}
/**
* returns true if this claim includes the given query
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*/
check(query) {
const { verb, resource } = extractVerbResource(query);
if (this.verb !== verb) return false;
if (this.isGlobal()) return true;
if (!resource) return false;
if (resource === this.resource) return true;
return resource.startsWith(`${this.resource}.`);
}
/**
* returns true if this claim represents exactly the same as the given query
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*/
isExact(query) {
const { verb, resource } = extractVerbResource(query);
if (this.verb !== verb) return false;
if (!resource) return this.isGlobal();
return resource === this.resource;
}
/**
* given a query, if this claim is a direct child of that query, it will return the immediate child part. Otherwise it returns null
*
* e.g.
* ```js
* const claim = buildClaim("read:what.some.stuff");
* claim.directChild("admin:*") // => null
* claim.directChild("read:*") // => null
* claim.directChild("read:what") // => null
* claim.directChild("read:what.some") // => "stuff"
* claim.directChild("read:what.some.stuff") // => null
* claim.directChild("read:what.some.stuff.blah") // => null
* ```
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*/
directChild(query) {
const { verb, resource } = extractVerbResource(query);
return this.lookupDirectChild(verb, resource);
}
/**
* return true if directChild() does not return null
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see directChild
*/
isDirectChild(query) {
return !!this.directChild(query);
}
/**
* given a query, if this claim is a direct descendant of that query, it will return the immediate child part. Otherwise it returns null
*
* e.g.
* ```js
* claim.directDescendant("admin:*") // => null
* claim.directDescendant("read:*") // => "what"
* claim.directDescendant("read:what") // => "some"
* claim.directDescendant("read:what.some") // => "stuff"
* claim.directDescendant("read:what.some.stuff") // => null
* claim.directDescendant("read:what.some.stuff.blah") // => null
* ```
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*/
directDescendant(query) {
const { verb, resource } = extractVerbResource(query);
return this.lookupDirectDescendant(verb, resource);
}
/**
* return true if isDirectDescendant() does not return null
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see isDirectDescendant
*/
isDirectDescendant(query) {
return !!this.directDescendant(query);
}
lookupDirectChild(verb, resource) {
if (!this.resource || !this.hasVerb(verb)) return null;
const resourceParts = partsFromResource(resource);
if (this.resourceParts.length !== resourceParts.length + 1) return null;
if (!resource) return this.resourceParts[0];
if (!this.resource.startsWith(`${resource}.`)) return null;
return `${this.resource}`.replace(`${resource}.`, "");
}
lookupDirectDescendant(verb, resource) {
if (!this.resource || !this.hasVerb(verb)) return null;
if (!resource) return this.resourceParts[0];
if (!this.resource.startsWith(`${resource}.`)) return null;
const index = partsFromResource(resource).length;
return this.resourceParts[index];
}
};
function buildClaim(stringOrObject) {
return new Claim(extractVerbResource(stringOrObject));
}
// src/lib/claims/claim-set.ts
var import_es_toolkit2 = require("es-toolkit");
var ClaimSet = class _ClaimSet {
constructor(claims) {
this.claims = claims;
this.frozen = true;
}
frozen;
_jsonString = null;
/**
* returns a string with the JSON representation of the claim set
*
* It is calculated only once and then memoized, but resets if the claimSet gets unfrozen
*/
toJSONString() {
if (!this._jsonString) {
this._jsonString = JSON.stringify(this.claims.map((x) => x.toString()));
}
return this._jsonString;
}
/**
* returns a new ClaimSet with clones of the same claims
*/
clone() {
return new _ClaimSet(this.claims.map((x) => x.clone()));
}
/**
* disallow any changes to the claim set. Resets the JSON string
*/
freeze() {
this._jsonString = null;
this.frozen = true;
}
/**
* allow changes to the claim set
*/
unfreeze() {
this.frozen = false;
}
/**
* returns True if the claim set does not allow any changes
*/
isFrozen() {
return this.frozen;
}
/**
* if the given claim is not `check` in the claim set already it will add it
* @param claim
*/
addIfNotChecked(claim) {
if (this.frozen) {
throw new FrozenClaimSetError("ClaimSet is frozen");
}
if (!this.check(claim)) {
this.claims.push(buildClaim(claim));
this.claims.sort();
}
}
/**
* if the given claim is not `hasExact` in the claim set already it will add it
* @param claim
*/
addIfNotExact(claim) {
if (this.frozen) {
throw new FrozenClaimSetError("ClaimSet is frozen");
}
if (!this.hasExact(claim)) {
this.claims.push(buildClaim(claim));
this.claims.sort();
}
}
/**
* returns true if any of the claims of the set returns true for the `check()` of the given query
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see Claim
*/
check(query) {
const parsedQuery = extractVerbResource(query);
return this.claims.some((claim) => claim.check(parsedQuery));
}
/**
* returns true if any of the claims of the set returns true for the `hasExact()` of the given query
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see Claim
*/
hasExact(query) {
const parsedQuery = extractVerbResource(query);
return this.claims.some((claim) => claim.isExact(parsedQuery));
}
/**
* collects from the claims of the set the result of `directChild()`
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see Claim
*/
directChildren(query) {
return this.mapInClaims(query, (claim, parsedQuery) => claim.directChild(parsedQuery));
}
/**
* collects from the claims of the set the result of `directDescendant()`
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see Claim
*/
directDescendants(query) {
return this.mapInClaims(query, (claim, parsedQuery) => claim.directDescendant(parsedQuery));
}
mapInClaims(query, fn) {
const parsedQuery = extractVerbResource(query);
const list = this.claims.map((claim) => fn(claim, parsedQuery));
return (0, import_es_toolkit2.uniq)((0, import_es_toolkit2.compact)(list)).sort();
}
};
function buildClaimSet(list) {
const claims = list.map((s) => buildClaim(s)).sort();
return new ClaimSet(claims);
}
// src/lib/claims/ability.ts
var Ability = class _Ability {
constructor(permitted, prohibited) {
this.permitted = permitted;
this.prohibited = prohibited;
}
/**
Returns a new ability with clones with the claim sets
*/
clone() {
return new _Ability(this.permitted.clone(), this.prohibited.clone());
}
/**
* returns a string with the permitted and prohibited jsons inside, for caching purposes
*/
get cacheID() {
return `(${this.permitted.toJSONString()},${this.prohibited.toJSONString()})`;
}
/**
* inverse of `can()`
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*
* @see can
*/
cannot(query) {
return !this.can(query);
}
/**
* return true if permitted is true and prohibited is false
* - permitted -> if the permitted ClaimSet returns true on `check()` for the given query
* - prohibited -> if the prohibited ClaimSet returns true on `check()` for the given query
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see ClaimSet
*/
can(query) {
const parsedQuery = extractVerbResource(query);
return this.permitted.check(parsedQuery) && !this.prohibited.check(parsedQuery);
}
/**
* returns true if there is a prohibited claim that returns true on `check()`
*
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
* @see ClaimSet
* @see Claim
*/
isExplicitlyProhibited(query) {
return this.prohibited.check(query);
}
/**
* returns a KeySet describing the access of this ability to the children of the given query:
* allows on direct descendants, forbids on direct children
* @param query can be a string ("verb:resource" or "verb:*") or an object with `verb` and `resource`
*/
accessToResources(query) {
const allowed = this.permitted.check(query) ? (0, import_key_set.all)() : (0, import_key_set.some)(this.permitted.directDescendants(query));
const forbidden = this.prohibited.check(query) ? (0, import_key_set.all)() : (0, import_key_set.some)(this.prohibited.directChildren(query));
return allowed.remove(forbidden);
}
};
function buildAbility(permittedStrings, prohibitedStrings) {
const permitted = buildClaimSet(permittedStrings);
const prohibited = buildClaimSet(prohibitedStrings);
return new Ability(permitted, prohibited);
}
// src/lib/claims/is-valid-claim-string.ts
function isValidClaimString(str) {
try {
extractVerbResource(str);
return true;
} catch (e) {
return false;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ALLOWED_VERBS,
Ability,
Claim,
ClaimSet,
FrozenClaimSetError,
InvalidPatternError,
InvalidVerbError,
buildAbility,
buildClaim,
buildClaimSet,
isAllowedVerb,
isValidClaimString
});
//# sourceMappingURL=index.js.map