This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-casbin.js.git
commit e21e9ed915dcb4db762a0b6f5ba4631fd758a4fd Author: Yang Luo <[email protected]> AuthorDate: Sun Nov 1 02:42:05 2026 +0800 feat: add cookies mode and improve permission loading --- README.md | 269 ++++++++++++++++++++---------------- src/Authorizer.ts | 298 ++++++++++++++++++++++++++++------------ src/Cache.ts | 86 ++++++++---- src/Permission.ts | 75 +++++----- src/__test__/authorizer.test.ts | 138 +++++++++++++++++++ src/__test__/cache.test.ts | 50 +++++-- src/__test__/cookie.test.ts | 74 ++++++++++ src/__test__/index.test.ts | 51 +++---- src/__test__/permission.test.ts | 38 +++-- src/__test__/server.ts | 76 +++++----- src/index.ts | 2 + src/types.ts | 19 ++- src/utils.ts | 3 - 13 files changed, 827 insertions(+), 352 deletions(-) diff --git a/README.md b/README.md index c3350d0..a03a681 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,153 @@ -# Casbin.js - -[](https://github.com/apache/casbin-casbin.js/actions) -[](https://github.com/apache/casbin-casbin.js/releases/latest) -[![NPM version][npm-image]][npm-url] -[![NPM download][download-image]][download-url] -[](https://packagephobia.now.sh/result?p=casbin.js) -[](https://discord.gg/S5UjpzGZjN) - -[npm-image]: https://img.shields.io/npm/v/casbin.js.svg?style=flat-square -[npm-url]: https://npmjs.org/package/casbin.js -[download-image]: https://img.shields.io/npm/dm/casbin.js.svg?style=flat-square -[download-url]: https://npmjs.org/package/casbin.js - -Casbin.js is the frontend library for [Casbin](https://casbin.apache.org), which facilitates the manipulation, management and storage of the user permission in a frontend application. - -## Example - -We demonstrate the usage of Casbin.js with [a React app](https://github.com/casbin-js/examples/tree/master/src). View the code to see more details. - - -You can use `manual` mode in Casbin.js, and set the permission whenever you wish. -```javascript -const casbinjs = require('casbin.js'); - -// Set the user's permission: -// He/She can read 2 objects: data1 and data2 -// Can write 1 objects: data1 -const permission = { - "read": ['data1', 'data2'], - "write": ['data1'] -} - -// Run casbin.js in manual mode, which requires you to set the permission manually. -const authorizer = new casbinjs.Authorizer("manual"); - -authorizer.setPermission(permission); - -authorizer.can("read", "data1").then(result => { - console.log(result) -}) -authorizer.cannot("write", "data2").then(result => { - console.log(result) -}); -``` - -In `manual` mode you can also pass the model and policies produced by the backend's -`CasbinJsGetPermissionForUser` (a JSON object with the keys `m`, `p` and `g`) to -`setPermission`. Casbin.js then builds a real enforcer from that data, so roles (`g` -rules) are taken into account. Remember to set the user, since the request is evaluated -as `(user, object, action)`. -```javascript -const authorizer = new casbinjs.Authorizer("manual"); - -// The JSON string or object returned by CasbinJsGetPermissionForUser -authorizer.setPermission(responseFromApi); -await authorizer.setUser("alice"); - -authorizer.can("read", "data1").then(result => { - console.log(result) -}); -``` - -### Users with several roles - -A user may hold any number of roles at the same time, and a role may inherit from -another role. Casbin.js evaluates the `g` rules with the same RBAC engine as the -backend, so the user gets the permissions of every one of those roles. This needs -the model and policies described above: the plain `{action: [objects]}` map holds no -roles at all, only the flattened result. -```javascript -// g, alice, admin -// g, alice, editor -// g, admin, root -const authorizer = new casbinjs.Authorizer("manual"); -authorizer.setPermission(responseFromApi); -await authorizer.setUser("alice"); - -// Granted by "admin", by "editor", and by the "root" role that "admin" inherits. -await authorizer.can("read", "data1"); - -// ["admin", "editor", "root"] -await authorizer.getRoles(); - -// [["admin", "data1", "read"], ["editor", "data2", "write"], ...] -await authorizer.getImplicitPermissions(); -``` - -For a model with domains, pass the domain to each of them: `authorizer.can("read", "data1", "domain1")`, -`authorizer.getRoles("domain1")`. - -You can also use the `auto` mode. In details, specify a casbin backend service endpoint when initializing the Casbin.js authorizer, and set the subject when the frontend user identity changes. Casbin.js will automatically fetch the permission from the endpoint. (A pre-configurated casbin service API is required at the backend.) -```javascript -const casbinjs = require('casbin.js'); - -// Set your backend casbin service url -const authorizer = new casbinjs.Authorizer('auto', {endpoint: 'http://Domain_name/casbin/api'}); - -// When the identity shifts, reset the user. Casbin.js will automatically fetch the permission from the endpoint. -await authorizer.setUser("Tom"); - -// Evaluate the permission -authorizer.can("read", "data1").then(); -``` - -More functionalities of Casbin.js are still under development. Feel free to raise issues to share your features suggestions! - -## TODO MAP -- [x] Permission cache. -- [ ] Cookie mode. -- [ ] Lightweight enforcer (avoid the abuse of async functions). -- [ ] Integration with other modern frontend frameworks. - - - - +# Casbin.js + +[](https://github.com/apache/casbin-casbin.js/actions) +[](https://github.com/apache/casbin-casbin.js/releases/latest) +[![NPM version][npm-image]][npm-url] +[![NPM download][download-image]][download-url] +[](https://packagephobia.now.sh/result?p=casbin.js) +[](https://discord.com/invite/S5UjpzGZjN) + +[npm-image]: https://img.shields.io/npm/v/casbin.js.svg?style=flat-square +[npm-url]: https://npmjs.org/package/casbin.js +[download-image]: https://img.shields.io/npm/dm/casbin.js.svg?style=flat-square +[download-url]: https://npmjs.org/package/casbin.js + +Casbin.js is the frontend authorization library of [Casbin](https://casbin.apache.org). It evaluates the permission of the current user in a browser with the same model and policies as the backend, for access control models like ACL, RBAC and ABAC. + +## Installation + +```bash +npm install casbin.js +``` + +or + +```bash +yarn add casbin.js +``` + +## Modes + +Casbin.js can load the permission in three ways: + +| Mode | How the permission arrives | +| --- | --- | +| `manual` | The application passes it to `setPermission` | +| `auto` | Casbin.js fetches it from a backend endpoint when the user changes | +| `cookies` | Casbin.js reads it from a cookie set by the backend | + +### Manual mode + +`setPermission` accepts either a map of actions to objects, or the model and policies produced by the backend's `CasbinJsGetPermissionForUser`. + +```javascript +const casbin = require('casbin.js'); + +// A map of actions to objects: the user can read data1 and data2, and write data1. +const authorizer = new casbin.Authorizer('manual'); +authorizer.setPermission({ + read: ['data1', 'data2'], + write: ['data1'], +}); + +await authorizer.can('read', 'data1'); // true +await authorizer.cannot('write', 'data2'); // true +``` + +The map form is a flattened result. Use the model and policies when the backend returns roles, so that Casbin.js evaluates the same RBAC model itself: + +```javascript +// The JSON string or object returned by CasbinJsGetPermissionForUser: +// { m: "...", p: [["p", "admin", "data1", "read"], ...], g: [["g", "alice", "admin"], ...] } +const authorizer = new casbin.Authorizer('manual'); +authorizer.setPermission(responseFromApi); +await authorizer.setUser('alice'); + +await authorizer.can('read', 'data1'); +await authorizer.getRoles(); // ["admin", ...] +await authorizer.getImplicitPermissions(); // [["admin", "data1", "read"], ...] +``` + +A user may hold several roles at the same time, and a role may inherit from another role. Casbin.js evaluates the `g` rules with the same RBAC engine as the backend, so the user gets the permissions of every one of those roles. + +For a model with domains, pass the domain to each call: `authorizer.can('read', 'data1', 'domain1')`, `authorizer.getRoles('domain1')`. + +### Auto mode + +Point Casbin.js at a backend endpoint that serves the permission of a subject. The endpoint receives the user as the `subject` query parameter and returns `{ "message": "ok", "data": "<the CasbinJsGetPermissionForUser result>" }`. + +```javascript +const authorizer = new casbin.Authorizer('auto', { + endpoint: 'https://example.com/casbin/api', + requestHeaders: { Authorization: 'Bearer ...' }, + cacheExpiredTime: 60, // seconds, defaults to 60 +}); + +// When the identity changes, Casbin.js fetches and caches the new permission. +await authorizer.setUser('alice'); +await authorizer.can('read', 'data1'); + +// The permission is cached in localStorage per endpoint and user. +authorizer.clearCache(); // drop the cached permission of the current user +await authorizer.refresh(); // drop it and load it again from the endpoint +``` + +`can` waits for a permission that `setUser` is still loading, so both of these work: + +```javascript +authorizer.setUser('alice'); +await authorizer.can('read', 'data1'); // waits for the request above +``` + +### Cookies mode + +If the backend stores the permission in a cookie, Casbin.js reads it at construction time. The default cookie key is `access_perm`; pass `cookieKey` to use another one. + +```javascript +// Reads the cookie "access_perm" and loads its JSON value. +const authorizer = new casbin.Authorizer('cookies'); + +// Or another cookie key. +const authorizer2 = new casbin.Authorizer('cookies', { cookieKey: 'my_perm' }); + +// Re-read the cookie after the backend refreshed it. +authorizer.loadPermissionFromCookie(); +``` + +The cookie value can be either form accepted by `setPermission`. With the model-and-policies form, call `setUser` before evaluating the permission. + +## API + +| Method | Description | +| --- | --- | +| `new Authorizer(mode, options)` | `mode` is `"manual"`, `"auto"` or `"cookies"`. Options: `endpoint`, `cacheExpiredTime`, `requestHeaders`, `cookieKey`. | +| `setPermission(permission)` | Load the permission, as a JSON string or an object. | +| `getPermission()` | Get the action-to-objects permission. Only available for the map form. | +| `setUser(user)` | Set the subject. In `auto` mode this loads the permission. | +| `refresh()` | Reload the permission from the endpoint, ignoring the cache. Only in `auto` mode. | +| `clearCache()` | Remove the cached permission of the current user. | +| `loadPermissionFromCookie()` | Re-read the permission cookie. Only in `cookies` mode. | +| `can(action, object[, domain])` | Whether the current user can perform the action on the object. | +| `cannot(action, object[, domain])` | The negation of `can`. | +| `canAll(action, objects[, domain])` | Whether `can` is true for every object. | +| `canAny(action, objects[, domain])` | Whether `can` is true for at least one object. | +| `getRoles([domain])` | Every role of the user, the inherited ones included. Requires the model and policies. | +| `getImplicitPermissions([domain])` | Every permission of the user, the ones from its roles included. Requires the model and policies. | + +## Development + +```bash +yarn install +yarn lint +yarn test +yarn build +``` + +## Example + +See the [React example](https://github.com/casbin-js/examples/tree/master/src) for a complete application. + +## TODO + +- [ ] Lightweight enforcer (avoid the abuse of async functions). +- [ ] Integration with other modern frontend frameworks. diff --git a/src/Authorizer.ts b/src/Authorizer.ts index c03ac3e..09e9b02 100644 --- a/src/Authorizer.ts +++ b/src/Authorizer.ts @@ -1,70 +1,80 @@ import * as casbin from 'casbin-core'; +import Cookies from 'js-cookie'; import Permission from './Permission'; -import { StringKV } from './types'; +import { EnforcerData, HeaderMap, PermissionSource, StringKV } from './types'; import * as Cache from './Cache'; interface BaseResponse { message: string; - data: any; + data: PermissionSource; } -type Mode = "auto" | "cookies" | "manual" +export type Mode = 'auto' | 'cookies' | 'manual'; + +export interface AuthorizerOptions { + /** Casbin service endpoint, required when mode == "auto" */ + endpoint?: string; + /** The expired time of the local cache, in seconds. Default: 60s, activated when mode == "auto" */ + cacheExpiredTime?: number; + /** Extra headers sent with every request to the endpoint */ + requestHeaders?: HeaderMap; + /** The cookie key when loading the permission, activated when mode == "cookies". Default: "access_perm" */ + cookieKey?: string; +} + +const DEFAULT_COOKIE_KEY = 'access_perm'; /** * Whether the object is the model + policies produced by casbin's * "CasbinJsGetPermissionForUser", rather than an action-to-objects map. */ -function isEnforcerData(obj: unknown): obj is Record<string, unknown> { +function isEnforcerData(obj: unknown): obj is EnforcerData { return typeof obj === 'object' && obj !== null && typeof (obj as Record<string, unknown>)['m'] === 'string'; } export class Authorizer { public mode!: Mode; - public endpoint: string | undefined = undefined; - public requestHeaders: StringKV | undefined = undefined; - public permission: Permission | undefined; - public cookieKey : string | undefined = undefined; - public cacheExpiredTime = 60; // Seconds - public user : string | undefined; - public enforcer:casbin.Enforcer | undefined; - private enforcerPromise: Promise<void> | undefined; + public endpoint?: string; + public requestHeaders?: HeaderMap; + public permission?: Permission; + public cookieKey?: string; + public cacheExpiredTime = 60; // Seconds + public user?: string; + public enforcer?: casbin.Enforcer; + + private enforcerPromise?: Promise<void>; + private userLoadPromise?: Promise<void>; + private userLoadTarget?: string; + /** Incremented whenever the permission is replaced, so a slow load cannot overwrite a newer one. */ + private stateVersion = 0; /** * * @param mode "auto", "cookies" or "manual" * "auto": Specify the casbin server endpoint, and Casbin.js will load permission from it when the identity changes - * "cookies": Casbin.js load the permission data from the cookie "access_perm" or the specified cookie key. - * "manual": Load the permission mannually with "setPermission" + * "cookies": Casbin.js loads the permission data from the cookie "access_perm" or the specified cookie key. + * "manual": Load the permission manually with "setPermission" * @param args * @param args.endpoint Casbin service endpoint, REQUIRED when mode == "auto" * @param args.cacheExpiredTime The expired time of local cache, Unit: seconds, Default: 60s, activated when mode == "auto" + * @param args.requestHeaders Extra headers sent with every request to the endpoint, activated when mode == "auto" * @param args.cookieKey The cookie key when loading permission, activated when mode == "cookies" */ - constructor(mode: Mode = 'manual', args: { endpoint?: string; cacheExpiredTime?: number; requestHeaders?: StringKV } = {}) { + constructor(mode: Mode = 'manual', args: AuthorizerOptions = {}) { if (mode == 'auto') { if (!args.endpoint) { throw new Error("Specify the endpoint when initializing casbin.js with mode == 'auto'"); - } else { - this.mode = mode; - this.endpoint = args.endpoint; - if (args.requestHeaders) { - this.requestHeaders = args.requestHeaders; - } - if (args.cacheExpiredTime !== null && args.cacheExpiredTime !== undefined) { - this.cacheExpiredTime = args.cacheExpiredTime; - } } - } else if (mode == 'cookies') { - throw Error("Cookie mode not implemented."); - /* this.mode = mode; - const permission = Cookies.get(args.cookieKey ? args.cookieKey : "access_perm"); - if (permission) { - this.setPermission(permission); - } else { - console.log("WARNING: No specified cookies"); + this.endpoint = args.endpoint; + this.requestHeaders = args.requestHeaders; + if (args.cacheExpiredTime !== null && args.cacheExpiredTime !== undefined) { + this.cacheExpiredTime = args.cacheExpiredTime; } - */ + } else if (mode == 'cookies') { + this.mode = mode; + this.cookieKey = args.cookieKey ?? DEFAULT_COOKIE_KEY; + this.loadPermissionFromCookie(); } else if (mode == 'manual') { this.mode = mode; } else { @@ -73,15 +83,17 @@ export class Authorizer { } /** - * Get the permission. + * Get the action-to-objects permission. Only available when the permission + * was loaded as such a map; the model and policies of + * "CasbinJsGetPermissionForUser" are evaluated by "can" instead. */ - public getPermission() : StringKV { - if (this.permission !== undefined) { - return this.permission?.getPermissionJsonObject(); - } else { - throw Error("Permission is not defined. Are you using manual mode and have set the permission?"); - return {} as StringKV; + public getPermission(): StringKV { + if (this.permission === undefined) { + throw Error( + 'No action-to-objects permission is loaded. "getPermission" only works with the map form passed to "setPermission".', + ); } + return this.permission.getPermissionJsonObject(); } /** @@ -93,7 +105,7 @@ export class Authorizer { * (a JSON object with the keys "m", "p" and "g"). In this case an enforcer is * built from that data, and "can" evaluates the model instead of a static map. */ - public setPermission(permission : Record<string, unknown> | string) : void{ + public setPermission(permission: Record<string, unknown> | string): void { const obj = typeof permission === 'string' ? JSON.parse(permission) : permission; if (isEnforcerData(obj)) { this.enforcerPromise = this.initEnforcer(obj); @@ -102,8 +114,9 @@ export class Authorizer { this.enforcerPromise.catch(() => undefined); return; } - // A previously built enforcer takes precedence in "can", so it has to go - // when the permission is replaced by a plain action-to-objects map. + // A previously built enforcer, or one that is still being built, must not + // take precedence in "can" once a plain action-to-objects map is set. + this.stateVersion++; this.enforcer = undefined; this.enforcerPromise = undefined; if (this.permission === undefined) { @@ -112,26 +125,34 @@ export class Authorizer { this.permission.load(obj); } - public async initEnforcer(s: string | Record<string, unknown>): Promise<void> { - const obj = typeof s === 'string' ? JSON.parse(s) : s; - if (!('m' in obj)) { - throw Error("No model when init enforcer."); + /** + * Build an enforcer from the model and policies of "CasbinJsGetPermissionForUser". + */ + public async initEnforcer(permission: PermissionSource): Promise<void> { + const obj = typeof permission === 'string' ? JSON.parse(permission) : permission; + if (!isEnforcerData(obj)) { + throw Error('No model when init enforcer.'); } + const version = ++this.stateVersion; // Drop the previous state first, so a failed or half-finished load never // leaves the permissions of the former identity in place. this.enforcer = undefined; this.permission = undefined; - const m = new casbin.Model(obj['m']); + const m = new casbin.Model(obj.m); const enforcer = await casbin.newEnforcer(m); // "CasbinJsGetPermissionForUser" puts the policies under "p" and the grouping // policies under "g", each rule prefixed with its own ptype. The grouping // policies carry the role assignments, including the several roles a single // user may have, and any role that inherits from another role. - for (const section of ['p', 'g']) { + for (const section of ['p', 'g'] as const) { if (section in obj) { await this.loadRules(enforcer, obj[section]); } } + // A newer permission replaced this one while the enforcer was being built. + if (version !== this.stateVersion) { + return; + } this.enforcer = enforcer; } @@ -140,10 +161,10 @@ export class Authorizer { return; } for (const rule of rules) { - if (!Array.isArray(rule)) { + if (!Array.isArray(rule) || rule.length === 0) { continue; } - const arr = (rule as string[]).map(v => v.trim()); + const arr = rule.map((v) => String(v).trim()); const pType = arr.shift(); if (!pType) { continue; @@ -157,39 +178,139 @@ export class Authorizer { } /** - * Initialize the enforcer + * Fetch the permission data from the configured endpoint. */ - public async getEnforcerDataFromSvr(): Promise<string>{ + public async getEnforcerDataFromSvr(): Promise<PermissionSource> { if (this.endpoint === undefined || this.endpoint === null) { - throw Error("Endpoint is null or not specified."); + throw Error('Endpoint is null or not specified.'); } - const resp = await fetch(`${this.endpoint}?subject=${this.user}`, { - headers: this.requestHeaders as unknown as HeadersInit, + const subject = this.requireUser(); + const separator = this.endpoint.includes('?') ? '&' : '?'; + const resp = await fetch(`${this.endpoint}${separator}subject=${encodeURIComponent(subject)}`, { + headers: this.requestHeaders, }); if (!resp.ok) { - throw new Error(`HTTP error: ${resp.status}`); + throw new Error(`Failed to load the permission from ${this.endpoint}: HTTP ${resp.status}`); + } + let body: BaseResponse; + try { + body = await resp.json(); + } catch (e) { + throw new Error(`The endpoint ${this.endpoint} did not return valid JSON.`); + } + if (body === null || typeof body !== 'object' || body.data === undefined) { + throw new Error(`The endpoint ${this.endpoint} did not return a "data" field.`); } - const body: BaseResponse = await resp.json(); return body.data; } /** - * Set the user subject for the authroizer + * Set the user subject for the authorizer. + * In "auto" mode the permission of the user is loaded from the endpoint, + * or from the local cache when it has not expired yet. * @param user The current user */ - public async setUser(user : string) : Promise<void> { - if (user === this.user) { + public async setUser(user: string): Promise<void> { + if (this.mode != 'auto') { + this.user = user; + return; + } + if (this.userLoadPromise !== undefined && this.userLoadTarget === user) { + // The same identity is already being loaded, share its result. + return this.userLoadPromise; + } + if (user === this.user && this.userLoadPromise === undefined && (this.enforcer !== undefined || this.permission !== undefined)) { + return; + } + await this.loadUser(user); + } + + /** + * Load the permission of the current user again from the endpoint, ignoring + * the local cache. Only available in "auto" mode. + */ + public async refresh(): Promise<void> { + if (this.mode != 'auto') { + throw Error('"refresh" is only available in auto mode.'); + } + const user = this.requireUser(); + Cache.removeLocalStorage(this.cacheKey(user)); + await this.loadUser(user); + } + + /** + * Remove the cached permission of the current user. + */ + public clearCache(): void { + if (this.user === undefined) { return; } + Cache.removeLocalStorage(this.cacheKey(this.user)); + } + + private cacheKey(user: string): string { + // Two authorizers on the same origin may point at different endpoints, + // so the same user name must not share a cached permission between them. + return this.endpoint === undefined ? user : `${this.endpoint}::${user}`; + } + + private async loadUser(user: string): Promise<void> { this.user = user; - if (this.mode == 'auto') { - let config = Cache.loadFromLocalStorage(user); - if (config === null) { + // The permission of the former identity must not stay in use while the + // new one is loading, otherwise a slow request would keep the old rights. + // Bumping the version also invalidates an enforcer that is still being built. + this.stateVersion++; + this.enforcer = undefined; + this.permission = undefined; + this.enforcerPromise = undefined; + const load = (async () => { + const key = this.cacheKey(user); + const cached = Cache.loadFromLocalStorage(key); + let config: PermissionSource; + if (cached === null) { config = await this.getEnforcerDataFromSvr(); - Cache.saveToLocalStorage(user, config, this.cacheExpiredTime); + Cache.saveToLocalStorage(key, typeof config === 'string' ? config : JSON.stringify(config), this.cacheExpiredTime); + } else { + config = cached; + } + // Another identity was requested while this one was loading. + if (this.user !== user) { + return; } await this.initEnforcer(config); + })(); + this.userLoadTarget = user; + this.userLoadPromise = load; + try { + await load; + } finally { + if (this.userLoadPromise === load) { + this.userLoadPromise = undefined; + this.userLoadTarget = undefined; + } + } + } + + /** + * Read the permission from the cookie and load it. Only available in "cookies" mode. + * @returns Whether a permission cookie was found. + */ + public loadPermissionFromCookie(): boolean { + if (this.mode != 'cookies') { + throw Error('"loadPermissionFromCookie" is only available in cookies mode.'); } + const key = this.cookieKey ?? DEFAULT_COOKIE_KEY; + const permission = Cookies.get(key); + if (permission === undefined) { + console.warn(`WARNING: No cookie named "${key}" was found.`); + return false; + } + try { + this.setPermission(permission); + } catch (e) { + throw new Error(`The cookie "${key}" does not contain a valid permission: ${(e as Error).message}`); + } + return true; } /** @@ -200,11 +321,10 @@ export class Authorizer { * @param domain The domain, for a model with domains */ public async getRoles(domain?: string): Promise<string[]> { - const enforcer = await this.getEnforcer(); + await this.settle(); + const enforcer = this.getEnforcerOrThrow(); const user = this.requireUser(); - return domain === undefined - ? await enforcer.getImplicitRolesForUser(user) - : await enforcer.getImplicitRolesForUser(user, domain); + return domain === undefined ? await enforcer.getImplicitRolesForUser(user) : await enforcer.getImplicitRolesForUser(user, domain); } /** @@ -213,48 +333,55 @@ export class Authorizer { * @param domain The domain, for a model with domains */ public async getImplicitPermissions(domain?: string): Promise<string[][]> { - const enforcer = await this.getEnforcer(); + await this.settle(); + const enforcer = this.getEnforcerOrThrow(); const user = this.requireUser(); return domain === undefined ? await enforcer.getImplicitPermissionsForUser(user) : await enforcer.getImplicitPermissionsForUser(user, domain); } - private async getEnforcer(): Promise<casbin.Enforcer> { + private async settle(): Promise<void> { + if (this.userLoadPromise !== undefined) { + await this.userLoadPromise; + } if (this.enforcerPromise !== undefined) { await this.enforcerPromise; } + } + + private getEnforcerOrThrow(): casbin.Enforcer { if (this.enforcer === undefined) { - throw Error("Enforcer not initialized. Roles are only available when the permission " + - "is the model and policies of \"CasbinJsGetPermissionForUser\"."); + throw Error( + 'Enforcer not initialized. Roles are only available when the permission ' + + 'is the model and policies of "CasbinJsGetPermissionForUser".', + ); } return this.enforcer; } private requireUser(): string { if (this.user === undefined) { - throw Error("User is not defined. Call \"setUser\" before evaluating the permission."); + throw Error('User is not defined. Call "setUser" before evaluating the permission.'); } return this.user; } public async can(action: string, object: string, domain?: string): Promise<boolean> { - if (this.enforcerPromise !== undefined) { - await this.enforcerPromise; - } + await this.settle(); if (this.enforcer !== undefined) { // Without a subject every request would silently be denied. - this.requireUser(); + const user = this.requireUser(); if (domain == undefined) { - return await this.enforcer.enforce(this.user, object, action); + return await this.enforcer.enforce(user, object, action); } else { - return await this.enforcer.enforce(this.user, domain, object, action); + return await this.enforcer.enforce(user, domain, object, action); } } - if (this.mode == "manual") { + if (this.mode == 'manual' || this.mode == 'cookies') { return this.permission !== undefined && this.permission.check(action, object); - } else if (this.mode == "auto") { - throw Error("Enforcer not initialized"); + } else if (this.mode == 'auto') { + throw Error('Permission is not loaded. Set the user with "setUser" first.'); } else { throw Error(`Mode ${this.mode} not recognized.`); } @@ -264,7 +391,7 @@ export class Authorizer { return !(await this.can(action, object, domain)); } - public async canAll(action: string, objects: string[], domain?: string) : Promise<boolean> { + public async canAll(action: string, objects: string[], domain?: string): Promise<boolean> { for (let i = 0; i < objects.length; ++i) { if (await this.cannot(action, objects[i], domain)) { return false; @@ -273,7 +400,7 @@ export class Authorizer { return true; } - public async canAny(action: string, objects: string[], domain?: string) : Promise<boolean> { + public async canAny(action: string, objects: string[], domain?: string): Promise<boolean> { for (let i = 0; i < objects.length; ++i) { if (await this.can(action, objects[i], domain)) { return true; @@ -281,5 +408,4 @@ export class Authorizer { } return false; } - } diff --git a/src/Cache.ts b/src/Cache.ts index 954bfee..8e4608a 100644 --- a/src/Cache.ts +++ b/src/Cache.ts @@ -1,59 +1,95 @@ +const STORAGE_PREFIX = 'casbinjs_'; + const isLocalStorageAvailable: boolean = (() => { if (typeof window === 'undefined' || !window.localStorage) { - return false + return false; } try { - const key = "fUjXn2r59"; // A random key - const value = "test"; - localStorage.setItem(key, value); - localStorage.getItem(key); - localStorage.removeItem(key); + const key = 'fUjXn2r59'; // A random key + window.localStorage.setItem(key, 'test'); + window.localStorage.getItem(key); + window.localStorage.removeItem(key); return true; } catch (e) { return false; } })(); -export function saveToLocalStorage(key: string, value: string, expired: number): number{ +function storageKey(key: string): string { + return `${STORAGE_PREFIX}${key}`; +} + +/** + * Save a permission to localStorage. + * @returns 0 on success, -1 when the cache is unavailable. Failing to cache + * the permission is not fatal, the caller can still use the in-memory copy. + */ +export function saveToLocalStorage(key: string, value: string, expired: number): number { if (!isLocalStorageAvailable) { return -1; } const savedItem = { value: value, - expired: Date.now() + 1000 * expired + expired: Date.now() + 1000 * expired, }; try { - localStorage.setItem(`casbinjs_${key}`, JSON.stringify(savedItem)); + window.localStorage.setItem(storageKey(key), JSON.stringify(savedItem)); + return 0; } catch (e) { - throw(e) - // TODO: Process the quotaExceededError + // Typically QuotaExceededError. The cache is only an optimization. + return -1; } - return 0; } /*** - * return: a string. - * If ret == null, it means there is no such user permission. + * Load a permission from localStorage. + * @returns The saved value, or null when there is no such entry or it expired. */ -export function loadFromLocalStorage(key: string): string | null{ +export function loadFromLocalStorage(key: string): string | null { if (!isLocalStorageAvailable) { return null; } - const itemStr = localStorage.getItem(`casbinjs_${key}`); - // No cache + let itemStr: string | null; + try { + itemStr = window.localStorage.getItem(storageKey(key)); + } catch (e) { + // Storage can become unavailable at runtime (for example in private mode). + return null; + } if (itemStr === null) { return null; } - const item = JSON.parse(itemStr); - - if (Date.now() > item["expired"]){ - localStorage.removeItem(`casbinjs_${key}`); + let item: unknown; + try { + item = JSON.parse(itemStr); + } catch (e) { + // A corrupted entry (another script, a manual edit) must not break the caller. + removeLocalStorage(key); + return null; + } + if (item === null || typeof item !== 'object') { + removeLocalStorage(key); + return null; + } + const { value, expired } = item as { value?: unknown; expired?: unknown }; + if (typeof value !== 'string' || typeof expired !== 'number') { + removeLocalStorage(key); + return null; + } + if (Date.now() > expired) { + removeLocalStorage(key); return null; - } else { - return item['value']; } + return value; } -export function removeLocalStorage(key: string) { - localStorage.removeItem(`casbinjs_${key}`); +export function removeLocalStorage(key: string): void { + if (!isLocalStorageAvailable) { + return; + } + try { + window.localStorage.removeItem(storageKey(key)); + } catch (e) { + // Nothing to do, the entry is gone from the cache one way or another. + } } diff --git a/src/Permission.ts b/src/Permission.ts index f31a9dc..d188b75 100644 --- a/src/Permission.ts +++ b/src/Permission.ts @@ -1,20 +1,29 @@ -import {StringKV} from './types'; +import { StringKV } from './types'; export default class Permission { - private actObjData : Map<string, Array<string>>; - private objActData : Map<string, Array<string>>; + private actObjData: Map<string, Array<string>>; + private objActData: Map<string, Array<string>>; public constructor() { this.actObjData = new Map<string, Array<string>>(); this.objActData = new Map<string, Array<string>>(); } - public load(permission : string | Record<string, unknown>) : void { - let p : StringKV; - if (typeof(permission) == 'string') { - p = JSON.parse(permission) as StringKV; - } else { - p = permission as StringKV; + public load(permission: string | Record<string, unknown>): void { + const p = typeof permission === 'string' ? JSON.parse(permission) : permission; + if (p === null || typeof p !== 'object' || Array.isArray(p)) { + throw new Error('The permission must be a JSON object that maps actions to arrays of objects.'); + } + + // Validate before touching the current permission, a malformed input + // must not leave a half-loaded one behind. + const entries: Array<[string, Array<string>]> = []; + for (const act of Object.keys(p)) { + const objects = (p as Record<string, unknown>)[act]; + if (!Array.isArray(objects)) { + throw new Error(`The permission of action "${act}" must be an array of objects.`); + } + entries.push([act, objects as Array<string>]); } // A load replaces the permission, it does not merge into the previous one. @@ -23,41 +32,43 @@ export default class Permission { this.actObjData.clear(); this.objActData.clear(); - // Generate data: {key:Actions, value: Array of objects} - for (const act in p) { - this.actObjData.set(act, p[act] as Array<string>); - } + const objActData = new Map<string, Array<string>>(); + for (const [act, objects] of entries) { + this.actObjData.set(act, objects); - // Generate data: {key:Objects, value: Array of actions} - const tmp : StringKV = {}; - for (const act in p) { - for (const obj of p[act]) { - if (!(obj in tmp)) { - tmp[obj] = []; + // Generate data: {key:Objects, value: Array of actions} + // A Map is used instead of an object so that an object name such + // as "__proto__" is stored as plain data. + for (const obj of objects) { + const actions = objActData.get(obj); + if (actions === undefined) { + objActData.set(obj, [act]); + } else { + actions.push(act); } - tmp[obj].push(act); } } - for (const obj in tmp) { - this.objActData.set(obj, tmp[obj] as Array<string>); - } - + objActData.forEach((actions, obj) => this.objActData.set(obj, actions)); } - public getPermissionJsonObject() : StringKV { - const obj : StringKV = {}; - this.actObjData.forEach((value, key) => (obj[key] = value)); + public getPermissionJsonObject(): StringKV { + const obj: StringKV = {}; + this.actObjData.forEach((value, key) => { + // defineProperty keeps an action named "__proto__" an ordinary own + // property instead of mutating the prototype of the result. + Object.defineProperty(obj, key, { value: value.slice(), enumerable: true, writable: true, configurable: true }); + }); return obj; } /* Parse the permission into JSON string */ - public getPermissionString() : string { + public getPermissionString(): string { return JSON.stringify(this.getPermissionJsonObject()); } - public getTargetsFromAction(action: string) : Array<string> { + public getTargetsFromAction(action: string): Array<string> { const result = this.actObjData.get(action); if (result === undefined) { return new Array<string>(); @@ -66,15 +77,15 @@ export default class Permission { } } - public getActionsObjects() : Map<string, Array<string>> { + public getActionsObjects(): Map<string, Array<string>> { return this.actObjData; } - public getObjectsActions() : Map<string, Array<string>> { + public getObjectsActions(): Map<string, Array<string>> { return this.objActData; } - public check(action : string, object : string) : boolean { + public check(action: string, object: string): boolean { const objects = this.actObjData.get(action); if (objects == undefined) { return false; diff --git a/src/__test__/authorizer.test.ts b/src/__test__/authorizer.test.ts new file mode 100644 index 0000000..2a3b693 --- /dev/null +++ b/src/__test__/authorizer.test.ts @@ -0,0 +1,138 @@ +import { Authorizer } from '../Authorizer'; +import { basicModelStr } from './models'; + +function permissionOf(user: string, object: string): string { + return JSON.stringify({ + m: basicModelStr, + p: [['p', user, object, 'read']], + }); +} + +test('can waits for the permission that setUser is still loading', async () => { + const authorizer = new Authorizer('auto', { endpoint: 'http://casbin.test/in-flight' }); + authorizer.getEnforcerDataFromSvr = () => Promise.resolve(permissionOf('alice', 'data1')); + + const loading = authorizer.setUser('alice'); + // No await on "loading" here, "can" has to wait for it by itself. + expect(await authorizer.can('read', 'data1')).toBe(true); + await loading; +}); + +test('a slow load for a former user does not overwrite the current one', async () => { + const authorizer = new Authorizer('auto', { endpoint: 'http://casbin.test/switch' }); + let releaseAlice: () => void = () => undefined; + const aliceLoading = new Promise<void>((resolve) => (releaseAlice = resolve)); + authorizer.getEnforcerDataFromSvr = async () => { + if (authorizer.user === 'alice') { + await aliceLoading; + return permissionOf('alice', 'alice-data'); + } + return permissionOf('bob', 'bob-data'); + }; + + const alice = authorizer.setUser('alice'); + const bob = authorizer.setUser('bob'); + await bob; + releaseAlice(); + await alice; + + expect(authorizer.user).toBe('bob'); + expect(await authorizer.can('read', 'bob-data')).toBe(true); + expect(await authorizer.can('read', 'alice-data')).toBe(false); +}); + +test('the cache of an endpoint is not shared with another endpoint', async () => { + const first = new Authorizer('auto', { endpoint: 'http://casbin.test/first' }); + first.getEnforcerDataFromSvr = () => Promise.resolve(permissionOf('alice', 'data1')); + await first.setUser('alice'); + expect(await first.can('read', 'data1')).toBe(true); + + const second = new Authorizer('auto', { endpoint: 'http://casbin.test/second' }); + second.getEnforcerDataFromSvr = () => Promise.resolve(permissionOf('alice', 'data2')); + await second.setUser('alice'); + + expect(await second.can('read', 'data2')).toBe(true); + expect(await second.can('read', 'data1')).toBe(false); +}); + +test('refresh ignores and updates the cached permission', async () => { + const authorizer = new Authorizer('auto', { endpoint: 'http://casbin.test/refresh', cacheExpiredTime: 3600 }); + let calls = 0; + authorizer.getEnforcerDataFromSvr = () => { + calls++; + return Promise.resolve(permissionOf('alice', calls === 1 ? 'data1' : 'data2')); + }; + + await authorizer.setUser('alice'); + expect(calls).toBe(1); + expect(await authorizer.can('read', 'data1')).toBe(true); + + await authorizer.refresh(); + expect(calls).toBe(2); + expect(await authorizer.can('read', 'data2')).toBe(true); + expect(await authorizer.can('read', 'data1')).toBe(false); +}); + +test('a second authorizer of the same endpoint and user uses the cache', async () => { + const endpoint = 'http://casbin.test/cached'; + const first = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 }); + let calls = 0; + first.getEnforcerDataFromSvr = () => { + calls++; + return Promise.resolve(permissionOf('alice', 'data1')); + }; + await first.setUser('alice'); + + const second = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 }); + second.getEnforcerDataFromSvr = () => { + calls++; + return Promise.resolve(permissionOf('alice', 'data2')); + }; + await second.setUser('alice'); + + expect(calls).toBe(1); + expect(await second.can('read', 'data1')).toBe(true); +}); + +test('clearCache removes the entry of the current user', async () => { + const endpoint = 'http://casbin.test/clear'; + let calls = 0; + const load = () => { + calls++; + return Promise.resolve(permissionOf('alice', 'data1')); + }; + + const authorizer = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 }); + authorizer.getEnforcerDataFromSvr = load; + await authorizer.setUser('alice'); + authorizer.clearCache(); + + const second = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 }); + second.getEnforcerDataFromSvr = load; + await second.setUser('alice'); + + expect(calls).toBe(2); +}); + +test('refresh is rejected outside auto mode', async () => { + const authorizer = new Authorizer('manual'); + await expect(authorizer.refresh()).rejects.toThrow(/only available in auto mode/); +}); + +test('setUser retries after a failed load', async () => { + const authorizer = new Authorizer('auto', { endpoint: 'http://casbin.test/retry' }); + let attempts = 0; + authorizer.getEnforcerDataFromSvr = () => { + attempts++; + if (attempts === 1) { + return Promise.reject(new Error('network down')); + } + return Promise.resolve(permissionOf('alice', 'data1')); + }; + + await expect(authorizer.setUser('alice')).rejects.toThrow('network down'); + await authorizer.setUser('alice'); + + expect(attempts).toBe(2); + expect(await authorizer.can('read', 'data1')).toBe(true); +}); diff --git a/src/__test__/cache.test.ts b/src/__test__/cache.test.ts index 8adcf30..23d1e59 100644 --- a/src/__test__/cache.test.ts +++ b/src/__test__/cache.test.ts @@ -1,11 +1,41 @@ import * as Cache from '../Cache'; -import * as utils from '../utils'; - -test("Cache unit test", async () => { - Cache.saveToLocalStorage("test", "test", 1); - expect(Cache.loadFromLocalStorage("test")).toBe("test"); - expect(localStorage.getItem("casbinjs_test")).not.toBeNull(); - await utils.sleep(2000); - expect(Cache.loadFromLocalStorage("test")).toBeNull(); - expect(localStorage.getItem("casbinjs_test")).toBeNull(); -}) + +describe('permission cache', () => { + beforeEach(() => { + localStorage.clear(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('saves and expires an entry', () => { + expect(Cache.saveToLocalStorage('test', 'value', 1)).toBe(0); + expect(Cache.loadFromLocalStorage('test')).toBe('value'); + expect(localStorage.getItem('casbinjs_test')).not.toBeNull(); + + jest.advanceTimersByTime(1001); + + expect(Cache.loadFromLocalStorage('test')).toBeNull(); + expect(localStorage.getItem('casbinjs_test')).toBeNull(); + }); + + test('ignores and removes a corrupted entry', () => { + localStorage.setItem('casbinjs_bad', '{not json'); + expect(Cache.loadFromLocalStorage('bad')).toBeNull(); + expect(localStorage.getItem('casbinjs_bad')).toBeNull(); + }); + + test('ignores and removes an entry with an unexpected shape', () => { + localStorage.setItem('casbinjs_bad', JSON.stringify({ value: 42 })); + expect(Cache.loadFromLocalStorage('bad')).toBeNull(); + expect(localStorage.getItem('casbinjs_bad')).toBeNull(); + }); + + test('removes an entry', () => { + Cache.saveToLocalStorage('test', 'value', 60); + Cache.removeLocalStorage('test'); + expect(localStorage.getItem('casbinjs_test')).toBeNull(); + }); +}); diff --git a/src/__test__/cookie.test.ts b/src/__test__/cookie.test.ts new file mode 100644 index 0000000..fe8b93a --- /dev/null +++ b/src/__test__/cookie.test.ts @@ -0,0 +1,74 @@ +import Cookies from 'js-cookie'; +import { Authorizer } from '../Authorizer'; +import { rbacModelStr } from './models'; + +describe('cookies mode', () => { + afterEach(() => { + Cookies.remove('access_perm'); + Cookies.remove('custom_perm'); + }); + + test('loads the permission from the default cookie', async () => { + Cookies.set('access_perm', JSON.stringify({ read: ['data1'] })); + + const authorizer = new Authorizer('cookies'); + + expect(authorizer.cookieKey).toBe('access_perm'); + expect(await authorizer.can('read', 'data1')).toBe(true); + expect(await authorizer.can('read', 'data2')).toBe(false); + expect(authorizer.getPermission()).toEqual({ read: ['data1'] }); + }); + + test('loads the permission from a custom cookie key', async () => { + Cookies.set('custom_perm', JSON.stringify({ write: ['data2'] })); + + const authorizer = new Authorizer('cookies', { cookieKey: 'custom_perm' }); + + expect(await authorizer.can('write', 'data2')).toBe(true); + expect(await authorizer.can('write', 'data1')).toBe(false); + }); + + test('reloads the permission when the cookie changes', async () => { + Cookies.set('access_perm', JSON.stringify({ read: ['data1'] })); + const authorizer = new Authorizer('cookies'); + + Cookies.set('access_perm', JSON.stringify({ read: ['data2'] })); + expect(authorizer.loadPermissionFromCookie()).toBe(true); + + expect(await authorizer.can('read', 'data2')).toBe(true); + expect(await authorizer.can('read', 'data1')).toBe(false); + }); + + test('supports the model and policies of CasbinJsGetPermissionForUser', async () => { + Cookies.set( + 'access_perm', + JSON.stringify({ + m: rbacModelStr, + p: [['p', 'admin', 'data1', 'read']], + g: [['g', 'alice', 'admin']], + }), + ); + + const authorizer = new Authorizer('cookies'); + await authorizer.setUser('alice'); + + expect(await authorizer.can('read', 'data1')).toBe(true); + expect(await authorizer.can('write', 'data1')).toBe(false); + expect(await authorizer.getRoles()).toEqual(['admin']); + }); + + test('reports a missing cookie', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const authorizer = new Authorizer('cookies'); + + expect(warn).toHaveBeenCalled(); + expect(await authorizer.can('read', 'data1')).toBe(false); + warn.mockRestore(); + }); + + test('rejects a malformed cookie', () => { + Cookies.set('access_perm', '{not json'); + expect(() => new Authorizer('cookies')).toThrow(/does not contain a valid permission/); + }); +}); diff --git a/src/__test__/index.test.ts b/src/__test__/index.test.ts index ce8dbe0..6da7201 100644 --- a/src/__test__/index.test.ts +++ b/src/__test__/index.test.ts @@ -1,15 +1,7 @@ import { Authorizer } from '../index'; import { basicModelStr } from './models'; import { basicPolicies } from './policies'; -import { removeLocalStorage } from '../Cache'; -import TestServer from './server'; - -test('Mock functions', () => { - // TODO: Use mock function to get response object - // const authorizer = new Authorizer('http://localhost:4000'); - // authorizer.setUser('alice'); - // expect(authorizer.getPermission()).toMatchObject(respObj); -}); +import TestServer from './server'; async function check(authorizer: Authorizer) { // can @@ -32,52 +24,43 @@ async function check(authorizer: Authorizer) { const permissionObj = { read: ['data1', 'data2'], - write: ['data2'] -} - -// test('Cookies mode', () => { -// const permissionObj = { -// read: ['data1', 'data2', 'data3'], -// write: ['data1'] -// } -// const cookieKey = 'test_perm' -// Cookies.set('test_perm', JSON.stringify(permissionObj)); -// const authorizer = new Authorizer('cookies', {cookieKey: cookieKey}); -// check(authorizer); -// }) + write: ['data2'], +}; -test('Manual mode', () => { +test('Manual mode', async () => { const authorizer = new Authorizer('manual'); authorizer.setPermission(permissionObj); - check(authorizer); -}) - + await check(authorizer); + expect(authorizer.getPermission()).toEqual(permissionObj); +}); test('Auto mode', async () => { const respData = JSON.stringify({ m: basicModelStr, p: basicPolicies, }); - const authorizer = new Authorizer("auto", {endpoint: "whatever"}); - removeLocalStorage('alice'); + const authorizer = new Authorizer('auto', { endpoint: 'whatever' }); await authorizer.initEnforcer(respData); - authorizer.user = "alice"; + authorizer.user = 'alice'; await check(authorizer); -}) +}); describe('Auto mode with server', () => { let server: TestServer; + beforeAll(async () => { server = new TestServer(); await server.start(); }); + afterAll(() => server.terminate()); + test('Request for /api/permissions', async () => { - removeLocalStorage('alice'); - const authorizer = new Authorizer('auto', { endpoint: 'http://localhost:4000/api/permissions'}); + const endpoint = `http://localhost:${server.port}/api/permissions`; + const authorizer = new Authorizer('auto', { endpoint }); await authorizer.setUser('alice'); await check(authorizer); + // The permission is cached for the next authorizer of the same endpoint and user. + expect(localStorage.getItem(`casbinjs_${endpoint}::alice`)).not.toBeNull(); }); - - afterAll(() => server.terminate()); }); diff --git a/src/__test__/permission.test.ts b/src/__test__/permission.test.ts index b5cd33e..dfe14f1 100644 --- a/src/__test__/permission.test.ts +++ b/src/__test__/permission.test.ts @@ -2,24 +2,23 @@ import Permission from '../Permission'; describe('Permission unit test', () => { const permExample = { - 'read': ['data1', 'data2'], - 'write': ['data2'] - } + read: ['data1', 'data2'], + write: ['data2'], + }; const permission = new Permission(); test('Load permission from JSON object', () => { - const s = JSON.stringify(permExample); permission.load(permExample); const t = permission.getPermissionJsonObject(); expect(t).toMatchObject(permExample); - }) + }); test('Load permission from JSON string', () => { const s = JSON.stringify(permExample); permission.load(s); const t = permission.getPermissionString(); expect(JSON.parse(t)).toMatchObject(permExample); - }) + }); test('Get targets from an action', () => { permission.load(permExample); @@ -31,8 +30,7 @@ describe('Permission unit test', () => { expected = permExample['write']; actual = permission.getTargetsFromAction('write'); expect(actual.sort()).toEqual(expected.sort()); - - }) + }); test('Get action-object mapping', () => { permission.load(permExample); @@ -46,6 +44,26 @@ describe('Permission unit test', () => { const actual = permission.getObjectsActions(); expect(actual.get('data1')).toEqual(['read']); expect(actual.get('data2')).toEqual(['read', 'write']); - }) + }); + + test('Reject a permission that does not map actions to arrays', () => { + expect(() => permission.load({ read: 'data1' } as any)).toThrow(/must be an array of objects/); + expect(() => permission.load('[]')).toThrow(/must be a JSON object/); + }); + + test('An action named "__proto__" stays an own property', () => { + permission.load(JSON.parse('{"__proto__": ["data1"]}')); + + const actual = permission.getPermissionJsonObject(); + expect(Object.prototype.hasOwnProperty.call(actual, '__proto__')).toBe(true); + expect(actual['__proto__']).toEqual(['data1']); + expect(({} as any).data1).toBeUndefined(); + }); -}) + test('The returned permission does not share its arrays with the internal state', () => { + permission.load(permExample); + permission.getPermissionJsonObject()['read'].push('data3'); + + expect(permission.check('read', 'data3')).toBe(false); + }); +}); diff --git a/src/__test__/server.ts b/src/__test__/server.ts index 090e559..49cccc0 100644 --- a/src/__test__/server.ts +++ b/src/__test__/server.ts @@ -1,92 +1,98 @@ -import * as http from "http"; +import * as http from 'http'; +import { AddressInfo } from 'net'; import express from 'express'; import { newEnforcer, Enforcer, Model } from 'casbin-core'; -import { StringKV } from '../types'; import { basicModelStr } from './models'; import { basicPolicies } from './policies'; class CasbinService { - private enforcer! : Enforcer; - + private enforcer!: Enforcer; + public async run() { // RBAC API doesn't support RBAC w/ domain. // this.enforcer = await newEnforcer('./src/__test__/example/rbac_with_domains_model.conf', './src/__test__/example/rbac_with_domains_policy.csv'); this.enforcer = await newEnforcer(new Model(basicModelStr)); for (const policy of basicPolicies) { - const arr = policy.map(v => v.trim()); + const arr = policy.map((v) => v.trim()); const pType = arr.shift(); if (pType === 'p') { await this.enforcer.addPolicy(...arr); } } } - - public async getEnforcerConfig(sub: string): Promise<string> { + public async getEnforcerConfig(sub: string): Promise<string> { const obj: any = {}; const m = this.enforcer.getModel().model; - let s = ""; - s += "[request_definition]\n"; - s += `r = ${m.get('r')?.get('r')?.value.replace(/_/g, ".")}\n`; - s += "[policy_definition]\n"; - s += `p = ${m.get('p')?.get('p')?.value.replace(/_/g, ".")}\n`; - if (m.get('g')?.get('g') !== undefined) { - s += "[role_definition]\n"; - s += `g = ${m.get('g')?.get('g')?.value}\n` - } - s += "[policy_effect]\n" - s += `e = ${m.get('e')?.get('e')?.value.replace(/_/g, ".")}\n`; - s += "[matchers]\n"; - s += `m = ${m.get('m')?.get('m')?.value.replace(/_/g, ".")}`; + let s = ''; + s += '[request_definition]\n'; + s += `r = ${m.get('r')?.get('r')?.value.replace(/_/g, '.')}\n`; + s += '[policy_definition]\n'; + s += `p = ${m.get('p')?.get('p')?.value.replace(/_/g, '.')}\n`; + if (m.get('g')?.get('g') !== undefined) { + s += '[role_definition]\n'; + s += `g = ${m.get('g')?.get('g')?.value}\n`; + } + s += '[policy_effect]\n'; + s += `e = ${m.get('e')?.get('e')?.value.replace(/_/g, '.')}\n`; + s += '[matchers]\n'; + s += `m = ${m.get('m')?.get('m')?.value.replace(/_/g, '.')}`; obj['m'] = s; obj['p'] = await this.enforcer.getPolicy(); for (const arr of obj['p']) { arr.splice(0, 0, 'p'); } - + return JSON.stringify(obj); } } class TestServer { private app: express.Application; - private port = 4000; private listener!: http.Server; private casbinServ: CasbinService; + private requestedPort: number; + /** The port the server is actually listening on, 0 picks a free one. */ + public port: number; + public constructor(port?: number) { - if (port) { - this.port = port; - } + this.requestedPort = port ?? 0; + this.port = this.requestedPort; this.app = express(); this.casbinServ = new CasbinService(); - } private setRouter(): void { this.app.get('/', (req: express.Request, res: express.Response) => { res.status(200).json({ message: 'ok', - data: 'this is the data' - }) + data: 'this is the data', + }); }); this.app.get('/api/permissions', async (req: express.Request, res: express.Response) => { - const sub = String(req.query["subject"]); + const sub = String(req.query['subject']); const config = await this.casbinServ.getEnforcerConfig(sub); res.status(200).json({ message: 'ok', - data: config - }) - }) + data: config, + }); + }); } - public async start() : Promise<void> { + public async start(): Promise<void> { await this.casbinServ.run(); this.setRouter(); - this.listener = this.app.listen(this.port, () => console.log(`Express server is listening at http://localhost:${this.port}`)); + await new Promise<void>((resolve) => { + this.listener = this.app.listen(this.requestedPort, () => { + this.port = (this.listener.address() as AddressInfo).port; + console.log(`Express server is listening at http://localhost:${this.port}`); + resolve(); + }); + }); } - public terminate() : void { + public terminate(): void { this.listener.close(); console.log('Express server is terminated'); } diff --git a/src/index.ts b/src/index.ts index 21cef9f..3a68ad9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ export * from './Authorizer'; +export * from './types'; +export { default as Permission } from './Permission'; diff --git a/src/types.ts b/src/types.ts index 95d7f5f..793b1b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,20 @@ export interface StringKV { - [key: string] : string[] + [key: string]: string[]; } + +export interface HeaderMap { + [key: string]: string; +} + +/** + * The model and policies produced by casbin's "CasbinJsGetPermissionForUser", + * or the same data after it has been parsed from JSON. + */ +export interface EnforcerData { + m: string; + p?: string[][]; + g?: string[][]; + [key: string]: unknown; +} + +export type PermissionSource = string | Record<string, unknown>; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 9f1dbdf..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
