Source

client/core/modules/acl.js

/**
 * 
 * ## A simple ACL manager
 * 
 * This small module provides an isomorphic record-based ACL.
 * 
 * The ACL ensures that a user has the PERMISSION to perform an ACTION.
 * To check this, the ACL configuration consists of RULES and VALIDATOR functions (aka "validators"):
 * 
 * ```
 *    acl: {
 *       permissions: {
 *           create: [
 *               { isOwner: true } // <= RULE
 *           ],
 *           read: [
 *               { authenticatedCanRead: true }, // RULE 1
 *               { isReader: true } // RULE 2 (evaluated only if RULE 1 is false)
 *           ],
 *           update: [
 *               { isOwner: true, isBanned: false }, // <= RULE with 2 conditions to fulfill
 *               { isDesigner: true }
 *           ],
 *           delete: [
 *               { isOwner: true }
 *           ],
 *           paintCar: [ // <= any arbitrary ACTION can be evaluated, not only CRUD operations
 *               { isOwner : true}
 *           ]
 *       },
 *   
 *       validators: {
 *           async isOwner({req}) { // <= VALIDATOR function
 *               if (kiss.isClient) return kiss.session.isOwner // <= ACL can be checked on the client or the server
 *               else return req.token.isOwner
 *           },
 *   
 *           async isDesigner({userACL, record}) {
 *               return kiss.tools.intersects(userACL, record.accessUpdate)
 *           },
 *   
 *           async authenticatedCanRead({record}) {
 *               return !!record.authenticatedCanRead
 *           },
 *   
 *           async isReader({userACL, record}) {
 *               if (record.accessRead && kiss.tools.intersects(userACL, record.accessRead)) return true
 *           },
 * 
 *           async isBanned({userACL, record}) {
 *               return (userACL.indexOf("banned") != -1)
 *           }
 *       }
 *    }
 * ```
 * 
 * This way, it's very straightforward to read the permissions, even with complex business cases.
 * 
 * In the example above, the PERMISSION to UPDATE requires the user:
 * - to be an owner AND not to be banned
 * OR
 * - to be a designer
 * 
 * The validator functions are called to evaluate those rules.
 * 
 * The rules are evaluated sequentially, and it stops if a rule is fulfilled.
 * A single rule can have multiple validators, for example:
 * { isOwner: false, isDesigner: true }
 * 
 * All the validators of a rule must pass to consider the rule fulfilled.
 * 
 * Inside a validator, we can use kiss.isClient and kiss.isServer to check where the code is executed.
 * This allows to have specific code depending on the execution context.
 * 
 * Validator functions used for creation and mutations (create, patch, delete) receive an object with 4 properties:
 * - req: the server request object
 * - userACL: an array of string containing all the names that identify a user, including groups.
 * - record: the database record we're trying to access
 * - model: the record's model
 * 
 * A validator function used for the "read" action receives an object with 3 properties:
 * - req: the server request object
 * - userACL
 * - record: the database record to evaluate. The validator returns true if the record matches the requirements.
 * 
 * For the "read" operation, the validators are evaluated against **each** record to filter data according to the user's permissions.
 * 
 * When executed on the CLIENT, the req property is not sent (the request doesn't exist here).
 * Validators are asynchronous because they sometimes need to retrieve database objects.
 * 
 * @namespace
 * 
 */
kiss.acl = {
    /**
     * Check a permission to perform an action on a record
     * 
     * @async
     * @param {object} config
     * @param {string} config.action - Ex: "create", "update", "read", "paintCar", "setCharacterName"
     * @param {object} config.record - CLIENT ONLY - record which we want to check the access rights
     * @param {object} config.req - SERVER ONLY - Server request object
     * @returns {boolean} true if permission is granted
     * 
     * @example
     * const record = await kiss.db.findOne("opportunity", {
     *  _id: "01890143-81ba-71bb-a1e9-155872656bdf"
     * })
     * 
     * const canUpdate = await kiss.acl.check({
     *  action: "update",
     *  record
     * })
     * console.log(canUpdate) // true or false
     */
    async check({action, record}) {
        const userACL = kiss.session.getACL()
        let model = record.model

        try {
            const acl = (kiss.tools.isUid(model.id)) ? kiss.app.models.dynamicModel.acl : model.acl
            
            // No acl defined = everyone has access
            if (!acl) return true

            // No permissions defined = everyone has access
            const permissions = acl.permissions
            if (!permissions) return true
    
            const permissionRules = permissions[action]
            if (!permissionRules) return true

            // Check every rule
            for (let rule of permissionRules) {
    
                let hasPermission = true
                const validators = Object.keys(rule)
    
                for (let validator of validators) {
                    const ruleTestValue = rule[validator]
                    const ruleFunction = acl.validators[validator]

                    if (ruleFunction) {
                        const permissionCheck = await ruleFunction({
                            userACL,
                            model,
                            record
                        })
                        
                        // Flag if a condition fails
                        if (permissionCheck != ruleTestValue) hasPermission = false
                        
                        log(`kiss.acl - check (client) - ${action} - Model: ${model.name} ${record.id.slice(0, 7)}... Permission: ${validator} = ${permissionCheck} - Access ${(hasPermission) ? "granted" :  "denied"}`, (hasPermission) ? 2 : 4)
                    }
                    else {
                        log(`kiss.acl - check (client) - Error: validator function <${validator}> is not defined for model ${model.id}`, 4)
                    }
                }
    
                // All conditions passed
                if (hasPermission) return true
            }
    
            return false

        } catch(err) {
            log(`kiss.acl - check (client) - Validator error - Model: ${model.id}`, 4, err)
            return false
        }
    },

    /**
     * Filter records for "read"" operations (find, findAndSort)
     * 
     * The rules defined in the "read" configuration of the acl object are evaluated against **each** record.
     * For this reason, this ACL system should be applied only to small sets of records (ex: workspaces, applications...)
     * but not on big sets of records (like dynamic tables with thousands of records).
     * 
     * For big sets of records, the ACL should not be record-based (per record), but model-based (per table/collection)
     * 
     * @async
     * @param {object} config
     * @param {object[]} config.records - records to filter
     * @param {object} config.req - SERVER ONLY - Server request
     * @returns {object[]} filtered records
     * 
     * @example
     * // Inside a NodeJS controller
     * const records = await kiss.db.find("opportunity", {}) // Retrieve all the opportunities
     * 
     * const authorizedRecords = await kiss.acl.filter({
     *  records,
     *  req
     * })
     * console.log(authorizedRecords) // Show only the records where the user has a read access
     * 
     */
    async filter({records}) {
        const userACL = kiss.session.getACL()
        const firstRecord = records[0]
        const model = firstRecord.model

        try {
            // No acl defined = everyone has access
            const acl = model.acl
            if (!acl) return true

            // No permissions defined = everyone has access
            const permissions = acl.permissions
            if (!permissions) return true

            const permissionRules = permissions.read
            if (!permissionRules) return true

            let hasPermission
            let result = []

            for (record of records) {

                // Check every rule
                for (let rule of permissionRules) {
                    const validators = Object.keys(rule)
                    hasPermission = true

                    // Check every validator of the rule
                    // The must ALL be fulfilled to validate the rule
                    for (let validator of validators) {
                        const ruleTestValue = rule[validator]
                        const ruleFunction = acl.validators[validator]

                        if (ruleFunction) {
                            const permissionCheck = await ruleFunction({
                                userACL,
                                record
                            })
                            
                            log("kiss.acl - filter (client) - Permission: " + validator + " / " + permissionCheck, (permissionCheck) ? 2 : 4)

                            // If a validator fails, the rule is not fulfilled => we skip to the next rule
                            if (permissionCheck != ruleTestValue) {
                                hasPermission = false
                                break
                            }
                        }
                    }

                    if (hasPermission) break
                }

                // All conditions passed
                if (hasPermission) result.push(record)
            }

            return result

        } catch (err) {
            log(`kiss.acl - filter (client) - Validator error - Model: ${model.id} / Validator: ${validator}`, 4, err)
            return false
        }
    }
}

;