UNPKG

@softvisio/core

Version:
1,528 lines (1,188 loc) • 50.6 kB
import constants from "#lib/app/constants"; import Permissions from "#lib/app/user/permissions"; import CacheLru from "#lib/cache/lru"; import sql from "#lib/sql"; import Counter from "#lib/threads/counter"; import Mutex from "#lib/threads/mutex"; import { mergeObjects } from "#lib/utils"; import AclUser from "./acl/user.js"; const SUGGEST_ACL_USERS_LIMIT = 10; const SQL = { "loadTypes": sql` SELECT json_object_agg( type, json_build_object( 'type', type, 'id', id, 'enabled', enabled, 'roles', ( SELECT json_object_agg( role, json_build_object( 'role', role, 'id', id, 'enabled', enabled, 'permissions', ( SELECT json_agg( permission ) FROM acl_permission WHERE acl_role_id = acl_role.id ) ) ) FROM acl_role WHERE acl_type.id = acl_role.acl_type_id ), 'notifications', ( SELECT json_object_agg( notification, json_build_object( 'notification', notification, 'id', id, 'enabled', enabled, 'roles', roles, 'channels', channels ) ) FROM acl_notification WHERE acl_type.id = acl_notification.acl_type_id ) ) ) AS acl FROM acl_type `, "getAclType": sql`SELECT acl_type.type FROM acl_type, acl WHERE acl.acl_type_id = acl_type.id AND acl.id = ?`.prepare(), "addAclUser": sql`INSERT INTO acl_user ( acl_id, user_id, enabled ) VALUES ( ?, ?, ? ) ON CONFLICT ( acl_id, user_id ) DO NOTHING`.prepare(), "getAclUser": sql` SELECT acl_type.id AS acl_type_id, acl_type.type, acl_user.enabled, acl_user_roles( acl_user.acl_id, acl_user.user_id ), acl_user_permissions( acl_user.acl_id, acl_user.user_id ) FROM acl_user, acl, acl_type WHERE acl_user.acl_id = ? AND acl_user.user_id = ? AND acl.id = acl_user.acl_id AND acl_type.id = acl.acl_type_id `.prepare(), "deleteAclUser": sql`DELETE FROM acl_user WHERE acl_id = ? AND user_id = ?`.prepare(), "setAclUserEnabled": sql`UPDATE acl_user SET enabled = ? WHERE acl_id = ? AND user_id = ?`.prepare(), "suggestAclUsers": sql` SELECT id, email, ? || "user".id AS avatar_url FROM "user" WHERE "user".email ILIKE ? ESCAPE '\\' AND "user".id NOT IN ( ?, ? ) AND "user".id NOT IN ( SELECT user_id FROM acl_user WHERE acl_id = ? ) LIMIT ? `, "getAclUserNotifications": sql` SELECT json_object_agg( acl_notification.notification, json_build_object( 'internal', acl_user_notification.internal, 'email', acl_user_notification.email, 'telegram', acl_user_notification.telegram, 'push', acl_user_notification.push ) ) AS notifications FROM acl_notification, acl_user_notification WHERE acl_user_notification.acl_id = ? AND acl_user_notification.user_id = ? AND acl_user_notification.acl_notification_id = acl_notification.id `.prepare(), "setAclUserNotificationSubscribed": sql` INSERT INTO acl_user_notification ( acl_id, user_id, acl_notification_id, internal, email, telegram, push ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE SET internal = EXCLUDED.internal, email = EXCLUDED.email, telegram = EXCLUDED.telegram, push = EXCLUDED.push `.prepare(), "setChannelSubscribed": { "internal": sql` INSERT INTO acl_user_notification ( acl_id, user_id, acl_notification_id, internal ) VALUES ( ?, ?, ?, ? ) ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE SET internal = EXCLUDED.internal `.prepare(), "email": sql` INSERT INTO acl_user_notification ( acl_id, user_id, acl_notification_id, email ) VALUES ( ?, ?, ?, ? ) ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE SET email = EXCLUDED.email `.prepare(), "telegram": sql` INSERT INTO acl_user_notification ( acl_id, user_id, acl_notification_id, telegram ) VALUES ( ?, ?, ?, ? ) ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE SET telegram = EXCLUDED.telegram `.prepare(), "push": sql` INSERT INTO acl_user_notification ( acl_id, user_id, acl_notification_id, push ) VALUES ( ?, ?, ?, ? ) ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE SET push = EXCLUDED.push `.prepare(), }, "getAclNotificationUsers": sql` SELECT "user".id, acl_user_notification.internal, acl_user_notification.email, acl_user_notification.telegram, acl_user_notification.push FROM "user", acl_user LEFT JOIN acl_user_notification ON ( acl_user.acl_id = acl_user_notification.acl_id AND acl_user.user_id = acl_user_notification.user_id AND acl_user_notification.acl_notification_id = ? ) WHERE acl_user.acl_id = ? AND acl_user.enabled AND acl_user.user_id = "user".id AND "user".enabled `.prepare(), "getAclNotificationRoleUsers": sql` WITH acl_user1 AS ( SELECT DISTINCT ON ( acl_user.user_id ) acl_user.user_id AS id, acl_user.acl_id FROM acl_user, acl_user_role WHERE acl_user.acl_id = ? AND acl_user.enabled = TRUE AND acl_user.acl_id = acl_user_role.acl_id AND acl_user.user_id = acl_user_role.user_id AND acl_user_role.acl_role_id IN ( SELECT json_array_elements_text( ? )::int8 ) ) SELECT "user".id, acl_user_notification.internal, acl_user_notification.email, acl_user_notification.telegram, acl_user_notification.push FROM "user", acl_user1 LEFT JOIN acl_user_notification ON ( acl_user1.acl_id = acl_user_notification.acl_id AND acl_user1.id = acl_user_notification.user_id AND acl_user_notification.acl_notification_id = ? ) WHERE acl_user1.id = "user".id AND "user".enabled `.prepare(), }; export default class Acl { #app; #config; #types = {}; #mutexSet = new Mutex.Set(); #aclIdCache; #aclObjectIdCache; #aclUserUserCache; #aclTypeRolesCache = {}; #activityCounter = new Counter(); #aclUserPermissionsCache = {}; constructor ( app, config ) { this.#app = app; this.#config = config; } // properties get app () { return this.#app; } get dbh () { return this.#app.dbh; } get config () { return this.#config; } // public async configure () { // get components acl for ( const component of this.app.components ) { if ( component.id === "acl" ) continue; const aclConfig = component.aclConfig; if ( !aclConfig ) continue; for ( const type in aclConfig ) { if ( !aclConfig[ type ].roles ) continue; // merge permissions for ( const role in aclConfig[ type ].roles ) { if ( !aclConfig[ type ].roles[ role ].permissions ) continue; if ( !this.#config.types[ type ]?.roles?.[ role ]?.permissions ) continue; aclConfig[ type ].roles[ role ].permissions = [ ...new Set( [ // ...this.#config.types[ type ].roles[ role ].permissions, ...aclConfig[ type ].roles[ role ].permissions, ] ), ]; } } // merge config mergeObjects( this.#config.types, aclConfig ); } return result( 200 ); } async init () { var res; // migrate database res = await this.dbh.schema.migrate( new URL( "db", import.meta.url ) ); if ( !res.ok ) return res; return result( 200 ); } async start () { const res = await this.#loadTypes(); if ( !res.ok ) return res; this.#aclIdCache = new CacheLru( { "maxSize": this.config.cacheMaxSize } ); this.#aclUserUserCache = new CacheLru( { "maxSize": this.config.cacheMaxSize } ).on( "delete", ( id, user ) => { delete this.#aclUserPermissionsCache[ user.id ]; } ); this.#aclObjectIdCache = new CacheLru( { "maxSize": this.config.cacheMaxSize } ); // setup dbh events this.dbh.on( "acl/type/update", () => { this.#aclIdCache.clear(); this.#aclIdCache.clear(); this.#aclObjectIdCache.clear(); this.#aclTypeRolesCache = {}; this.#aclUserUserCache.clear( { "silent": true } ); this.#aclUserPermissionsCache = {}; this.#loadTypes(); } ); this.dbh.on( "acl/update", data => { const cacheId = data.acl_id + "/" + data.user_id, user = this.#aclUserUserCache.get( cacheId ); user?.updateFields( data ); delete this.#aclUserPermissionsCache[ data.user_id ]; } ); this.dbh.on( "acl/delete", data => { const cacheId = data.acl_id + "/" + data.user_id; this.#aclUserUserCache.delete( cacheId ); } ); this.dbh.on( "disconnect", () => { this.#aclUserUserCache.clear( { "silent": true } ); this.#aclUserPermissionsCache = {}; } ); return result( 200 ); } async destroy () { return this.#activityCounter.wait(); } async updateTypes ( types ) { var res; // sync data res = await this.dbh.begin( async dbh => { var res, updated; // set transaction level lock res = await dbh.selectRow( sql`SELECT pg_advisory_xact_lock( ${ dbh.schema.getLockId( "acl/sync" ) } )` ); if ( !res.ok ) throw res; res = await dbh.selectRow( SQL.loadTypes ); if ( !res.ok ) throw res; const index = res.data?.acl || {}; // delete types { const deletedTypes = []; // search for deleted types for ( const type of Object.values( index ) ) { if ( !types[ type.type ] && type.enabled ) deletedTypes.push( type.id ); } // disable types if ( deletedTypes.length ) { res = await dbh.do( sql`UPDATE acl_type SET enabled = FALSE WHERE id`.IN( deletedTypes ) ); if ( !res.ok ) throw res; // disable roles res = await dbh.do( sql`UPDATE acl_role SET enabled = FALSE WHERE acl_type_id`.IN( deletedTypes ) ); if ( !res.ok ) throw res; // delete permissions res = await dbh.do( sql`DELETE FROM acl_permission USING acl_role WHERE acl_permission.acl_role_id = acl_role.id AND acl_role.acl_type_id`.IN( deletedTypes ) ); if ( !res.ok ) throw res; // disable notifications res = await dbh.do( sql`UPDATE acl_notification SET enabled = FALSE WHERE acl_type_id`.IN( deletedTypes ) ); if ( !res.ok ) throw res; updated = true; } } // sync types for ( const type of Object.values( types ) ) { // add type if ( !index[ type.type ] ) { // default type if ( type.type === constants.mainAclType ) { res = await dbh.selectRow( sql`INSERT INTO acl_type ( id, type ) VALUES ( ?, ? ) RETURNING id`, [ // constants.mainAclId, type.type, ] ); } else { res = await dbh.selectRow( sql`INSERT INTO acl_type ( type ) VALUES ( ? ) RETURNING id`, [ // type.type, ] ); } if ( !res.ok ) throw res; updated = true; index[ type.type ] = { "id": res.data.id, "enabled": true, "roles": {}, }; } // enable type else if ( !index[ type.type ].enabled ) { res = await dbh.do( sql`UPDATE acl_type SET enabled = TRUE WHERE id = ?`, [ index[ type.type ].id ] ); if ( !res.ok ) throw res; updated = true; index[ type.type ].enabled = true; } // roles { // search for deleted roles const deletedRoles = []; for ( const role of Object.values( index[ type.type ].roles ) ) { if ( !type.roles[ role.role ] ) deletedRoles.push( role.id ); } // delete roles if ( deletedRoles.length ) { res = await dbh.do( sql`UPDATE acl_role SET enabled = FALSE WHERE id`.IN( deletedRoles ) ); if ( !res.ok ) throw res; // delete permissions res = await dbh.do( sql`DELETE FROM acl_permission WHERE acl_role_id`.IN( deletedRoles ) ); if ( !res.ok ) throw res; } // sync roles for ( const role of Object.values( type.roles ) ) { const indexedRole = index[ type.type ].roles[ role.role ]; // add role if ( !indexedRole ) { res = await dbh.selectRow( sql`INSERT INTO acl_role ( acl_type_id, role ) VALUES ( ?, ? ) RETURNING id`, [ // index[ type.type ].id, role.role, ] ); if ( !res.ok ) throw res; updated = true; index[ type.type ].roles[ role.role ] = { "id": res.data.id, "enabled": true, "permissions": [], }; } // update role else if ( !indexedRole.enabled ) { res = await dbh.do( sql`UPDATE acl_role SET enabled = TRUE WHERE id = ?`, [ // indexedRole.id, ] ); if ( !res.ok ) throw res; updated = true; index[ type.type ].roles[ role.role ].enabled = true; } // permissions { const localPermissions = new Set( role.permissions ), remotePermissions = new Set( index[ type.type ].roles[ role.role ].permissions ); // scan for deleted permissions const deletedPermissions = []; for ( const permission of remotePermissions ) { if ( !localPermissions.has( permission ) ) deletedPermissions.push( permission ); } // delete permissions if ( deletedPermissions.length ) { res = await dbh.do( sql`DELETE FROM acl_permission WHERE acl_role_id = ${ index[ type.type ].roles[ role.role ].id } AND permission`.IN( deletedPermissions ) ); if ( !res.ok ) throw res; updated = true; } // scan for added permissions const addedPermissions = []; for ( const permission of localPermissions ) { if ( !remotePermissions.has( permission ) ) { addedPermissions.push( { "acl_role_id": index[ type.type ].roles[ role.role ].id, permission, } ); } } // add permissions if ( addedPermissions.length ) { res = await dbh.do( sql`INSERT INTO acl_permission`.VALUES( addedPermissions ) ); if ( !res.ok ) throw res; updated = true; } } } } // notifications { // search for deleted notifications if ( index[ type.type ].notifications ) { const deletedNotifications = []; for ( const notification of Object.values( index[ type.type ].notifications ) ) { if ( !type.notifications?.[ notification.notification ] ) deletedNotifications.push( notification.id ); } // delete notifications if ( deletedNotifications.length ) { res = await dbh.do( sql`UPDATE acl_notification SET enabled = FALSE WHERE id`.IN( deletedNotifications ) ); if ( !res.ok ) throw res; } } // sync notifications if ( type.notifications ) { for ( const notification of Object.values( type.notifications ) ) { const indexedNotification = index[ type.type ].notifications?.[ notification.notification ]; // add notification if ( !indexedNotification ) { res = await dbh.selectRow( sql`INSERT INTO acl_notification ( acl_type_id, notification, roles, channels ) VALUES ( ?, ?, ?, ? ) RETURNING id`, [ // index[ type.type ].id, notification.notification, notification.roles, notification.channels, ] ); if ( !res.ok ) throw res; updated = true; } // update notification else { const localRoles = JSON.stringify( notification.roles ), remoteRoles = JSON.stringify( indexedNotification.roles ); const localChannels = JSON.stringify( notification.channels ), remoteChannels = JSON.stringify( indexedNotification.channels ); if ( !indexedNotification.enabled || localRoles !== remoteRoles || localChannels !== remoteChannels ) { res = await dbh.do( sql`UPDATE acl_notification SET enabled = TRUE, roles = ?, channels = ? WHERE id = ?`, [ // notification.roles, notification.channels, indexedNotification.id, ] ); if ( !res.ok ) throw res; updated = true; } } } } } } // create default acl res = await dbh.do( sql`INSERT INTO acl ( id, acl_type_id ) VALUES ( ?, ? ) ON CONFLICT ( id ) DO NOTHING`, [ constants.mainAclId, constants.mainAclId ] ); if ( !res.ok ) throw res; if ( updated ) { res = await dbh.select( sql`SELECT pg_notify( ?, NULL )`, [ "acl/type/update" ] ); if ( !res.ok ) throw res; } } ); if ( !res.ok ) return res; return result( 200 ); } async resolveAclPermissions ( userId, aclResolvers ) { // user is guest if ( !userId ) { return Permissions.guestsPermissions; } // user is root else if ( this.app.userIsRoot( userId ) ) { return Permissions.rootPermissions; } else { const aclIds = []; // resolve acl if ( aclResolvers ) { for ( const aclResolver of aclResolvers ) { const resolvedAclId = await this.#resolveAclId( aclResolver.id, aclResolver.resolver ); // acl not resolved if ( !resolvedAclId ) return; aclIds.push( resolvedAclId ); } } return this.getAclUserFullPermissions( aclIds, userId ); } } async suggestAclUsers ( aclId, query, parentUserId ) { query = query ? `%${ sql.quoteLikePattern( query ) }%` : "%"; return this.dbh.select( SQL.suggestAclUsers, [ // this.app.api.config.avatarUrl, query, constants.rootUserId, parentUserId, aclId, SUGGEST_ACL_USERS_LIMIT, ] ); } async addAclUser ( aclId, userId, { enabled = true, roles, parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; res = await dbh.begin( async dbh => { var res; res = await dbh.do( SQL.addAclUser, [ aclId, userId, enabled ] ); if ( !res.ok ) throw res; if ( !res.meta.rows ) throw "Unable to add user"; if ( roles ) { res = await this.#setAclUserRoles( aclId, userId, roles, { parentUserId, dbh } ); if ( !res.ok ) throw res; } } ); return res; } async deleteAclUser ( aclId, userId, { parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( !user ) { return result( [ 400, "Unable to delete ACL user" ] ); } // check parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; const parentUserPermissions = res.data; if ( !parentUserPermissions.hasAll( user.permissions ) ) { return result( [ 400, "Unable to delete ACL user" ] ); } } res = await dbh.do( SQL.deleteAclUser, [ aclId, userId ] ); if ( res.meta.rows ) { // drop cache const cacheId = aclId + "/" + userId; this.#aclUserUserCache.delete( cacheId ); } return res; } async setAclUserEnabled ( aclId, userId, enabled, { parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; if ( aclId === constants.mainAclId ) return result( [ 400, "Unable to edit maun ACL" ] ); dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( !user ) { return result( [ 400, "Unable to update ACL user" ] ); } // check parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; const parentUserPermissions = res.data; if ( !parentUserPermissions.hasAll( user.permissions ) ) { return result( [ 400, "Unable to update ACL user" ] ); } } res = await dbh.do( SQL.setAclUserEnabled, [ enabled, aclId, userId ] ); if ( res.meta.rows ) { // update cached user this.#aclUserUserCache.get( aclId + "/" + userId )?.updateFields( { enabled } ); } return res; } async setAclUserRoles ( aclId, userId, roles, { parentUserId, dbh } = {} ) { const res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( user === false ) { return result( [ 400, "Unable to add ACL user roles" ] ); } else if ( !user ) { return this.addAclUser( aclId, userId, { roles, parentUserId, dbh } ); } else { return this.#setAclUserRoles( aclId, userId, roles, { parentUserId, dbh } ); } } async addAclUserRoles ( aclId, userId, roles, { parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( user === false ) { return result( [ 400, "Unable to add ACL user roles" ] ); } else if ( !user ) { return this.addAclUser( aclId, userId, { roles, parentUserId, dbh } ); } const aclType = user.aclType; // check parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; var parentUserPermissions = res.data; } if ( !roles ) { roles = []; } else if ( !Array.isArray( roles ) ) { roles = [ roles ]; } roles = new Set( roles ); const addRoles = []; for ( const role of roles ) { if ( !this.#types[ aclType ].roles[ role ] ) return result( [ 400, "ACL roles are invalid" ] ); if ( parentUserPermissions && !parentUserPermissions.hasAll( this.#types[ aclType ].roles[ role ].permissions ) ) { return result( [ 400, "ACL roles are invalid" ] ); } if ( !user.hasRoles( role ) ) addRoles.push( role ); } if ( !addRoles.length ) return result( 200 ); res = await dbh.do( sql`INSERT INTO acl_user_role`.VALUES( addRoles.map( role => { return { "acl_id": aclId, "user_id": userId, "acl_role_id": sql`SELECT acl_role.id FROM acl_role, acl_type WHERE acl_role.acl_type_id = acl_type.id AND acl_type.type = ${ aclType } AND acl_role.role = ${ role }`, }; } ) ) ); return res; } async deleteAclUserRoles ( aclId, userId, roles, { parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( !user ) { return result( [ 400, "Unable to delete ACL user roles" ] ); } const aclType = user.aclType; // check parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; var parentUserPermissions = res.data; } if ( !roles ) { roles = []; } else if ( !Array.isArray( roles ) ) { roles = [ roles ]; } roles = new Set( roles ); const deleteRoles = []; for ( const role of roles ) { if ( !this.#types[ aclType ].roles[ role ] ) return result( [ 400, "ACL roles are invalid" ] ); if ( parentUserPermissions && !parentUserPermissions.hasAll( this.#types[ aclType ].roles[ role ].permissions ) ) { return result( [ 400, "ACL roles are invalid" ] ); } if ( user.hasRoles( role ) ) deleteRoles.push( role ); } if ( !deleteRoles.length ) return result( 200 ); res = await dbh.do( sql` DELETE FROM acl_user_role WHERE acl_id = ${ aclId } AND user_id = ${ userId } AND acl_role_id IN ( SELECT acl_role.id FROM acl_role, acl_type WHERE acl_role.acl_type_id = acl_type.id AND acl_type.type = ${ aclType } AND acl_role.role`.IN( deleteRoles ).sql` ) ` ); return res; } async getAclUserRoles ( aclId, userId, { parentUserId } = {} ) { if ( this.app.userIsRoot( userId ) ) return result( [ 400, "Unable to get ACL roles" ] ); // get parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; var parentUserPermissions = res.data; } // get user if ( userId ) { var user = await this.getAclUser( aclId, userId ); if ( user === false ) return result( [ 400, "Unable to get ACL roles" ] ); } // get acl type const aclType = user?.aclType || ( await this.#getAclType( aclId ) ); if ( !aclType ) return result( [ 400, "ACL id is invalid" ] ); const roles = []; for ( const role of Object.values( this.#types[ aclType ].roles ) ) { let readonly; if ( userId === parentUserPermissions?.userId ) { readonly = true; } else if ( parentUserPermissions ) { readonly = !parentUserPermissions.hasAll( role.permissions ); } else { readonly = false; } const enabled = user ? user.hasRoles( role.role ) : false; if ( !enabled && readonly ) continue; roles.push( { "id": role.role, "name": role.name, "description": role.description, enabled, readonly, } ); } return result( 200, roles ); } async getAclUserPermissions ( aclId, userId ) { if ( !userId ) { return Permissions.guestsPermissions; } else if ( this.app.userIsRoot( userId ) ) { return Permissions.rootPermissions; } else { const user = await this.getAclUser( aclId, userId ); // error if ( user === false ) { // not found or disabled return; } else if ( !user?.isEnabled ) { return Permissions.guestsPermissions; } else { return user.permissions; } } } async getAclUserFullPermissions ( aclIds, userId ) { if ( !userId ) { return Permissions.guestsPermissions; } else if ( this.app.userIsRoot( userId ) ) { return Permissions.rootPermissions; } else { if ( !Array.isArray( aclIds ) ) aclIds = [ aclIds ]; aclIds = [ ...new Set( [ constants.mainAclId, ...aclIds ] ) ].filter( aclId => aclId ).sort(); const id = aclIds.join( "/" ); if ( !this.#aclUserPermissionsCache[ userId ]?.[ id ] ) { const permissions = []; for ( const aclId of aclIds ) { const user = await this.getAclUser( aclId, userId ); // error if ( user === false ) { return; } else if ( user?.isEnabled ) { permissions.push( ...user.permissions ); } } this.#aclUserPermissionsCache[ userId ] ??= {}; this.#aclUserPermissionsCache[ userId ][ id ] = new Permissions( userId, permissions ); } return this.#aclUserPermissionsCache[ userId ]?.[ id ]; } } async getAclRoles ( aclId ) { const aclType = await this.#getAclType( aclId ); if ( !aclType ) return result( [ 400, "ACL id is invalid" ] ); var data = this.#aclTypeRolesCache[ aclType ]; if ( !data ) { data = []; for ( const role of Object.values( this.#types[ aclType ].roles ) ) { data.push( { "id": role.role, "name": role.name, "description": role.description, } ); } this.#aclTypeRolesCache[ aclType ] = data; } return result( 200, data ); } async sendAclNotification ( aclId, notificationType, subject, body ) { this.#activityCounter.value++; const res = await this.#sendAclNotification( aclId, notificationType, subject, body ); this.#activityCounter.value--; return res; } async getAclUserNotificationsProfile ( aclId, userId ) { const user = await this.getAclUser( aclId, userId ); if ( !user ) return result( [ 500, "ACL user not found" ] ); const res = await this.dbh.selectRow( SQL.getAclUserNotifications, [ aclId, userId ] ); if ( !res.ok ) return res; const userNotifications = res.data?.notifications || {}; const notifications = []; for ( const notification of Object.values( this.#types[ user.aclType ].notifications ) ) { ROLES: if ( notification.roles ) { for ( const role of notification.roles ) { if ( user.hasRoles( role ) ) break ROLES; } continue; } const notificationProfile = { "id": notification.notification, "name": notification.name, "description": notification.description, "channels": {}, }; notifications.push( notificationProfile ); for ( const channel of Object.values( notification.channels ) ) { notificationProfile.channels[ channel.channel ] = { "enabled": channel.enabled, "editable": channel.editable, "subscribed": channel.editable ? ( userNotifications[ notification.notification ]?.[ channel.channel ] ?? channel.subscribed ) : channel.subscribed, }; } } return result( 200, notifications ); } async setAclUserNotificationSubscribed ( { aclId, userId, notification, channel, subscribed } = {} ) { const user = await this.getAclUser( aclId, userId ); if ( !user ) return result( [ 500, "ACL user not found" ] ); if ( !notification ) { // channel is not valid or disabled if ( channel && !this.app.notifications.channels[ channel ]?.enabled ) return result( [ 400, "Notification channel is not valid" ] ); const values = [], set = {}; for ( const notification of Object.values( this.#types[ user.aclType ]?.notifications ) ) { if ( channel ) { values.push( { "acl_id": aclId, "user_id": userId, "acl_notification_id": notification.id, [ channel ]: subscribed, } ); } else { values.push( { "acl_id": aclId, "user_id": userId, "acl_notification_id": notification.id, "internal": subscribed, "email": subscribed, "telegram": subscribed, "push": subscribed, } ); } } // no notifications if ( !values.length ) return result( 200 ); if ( channel ) { set[ channel ] = sql( `EXCLUDED.${ channel }` ); } else { set.internal = sql`EXCLUDED.internal`; set.email = sql`EXCLUDED.email`; set.telegram = sql`EXCLUDED.telegram`; set.push = sql`EXCLUDED.push`; } return this.dbh.do( sql`INSERT INTO acl_user_notification`.VALUES( values ).sql`ON CONFLICT ( acl_id, user_id, acl_notification_id ) DO UPDATE`.SET( set ) ); } else { notification = this.#types[ user.aclType ]?.notifications?.[ notification ]; if ( !notification ) return result( [ 400, "Invalid ACL notification" ] ); if ( channel ) { if ( !notification.channels[ channel ] ) return result( [ 400, "Notification channel is not valid" ] ); if ( !notification.channels[ channel ].editable ) return result( [ 400, "ACL notification channel is read-only" ] ); return this.dbh.do( SQL.setChannelSubscribed[ channel ], [ // aclId, userId, notification.id, subscribed, ] ); } else { return this.dbh.do( SQL.setAclUserNotificationSubscribed, [ // aclId, userId, notification.id, subscribed, subscribed, subscribed, subscribed, ] ); } } } async getAclUser ( aclId, userId, { dbh } = {} ) { const cacheId = aclId + "/" + userId; var user = this.#aclUserUserCache.get( cacheId ); if ( user ) return user; const mutex = this.#mutexSet.get( `user/${ cacheId }` ); if ( !mutex.tryLock() ) return mutex.wait(); dbh ||= this.dbh; const res = await dbh.selectRow( SQL.getAclUser, [ aclId, userId ] ); if ( !res.ok ) { user = false; } else if ( res.data ) { user = new AclUser( this, { "id": userId, aclId, "aclType": res.data.type, "aclTypeId": res.data.acl_type_id, "enabled": res.data.enabled, "roles": res.data.acl_user_roles, "permissions": res.data.acl_user_permissions, } ); this.#aclUserUserCache.set( cacheId, user ); } else { user = null; } mutex.unlock( user ); return user; } // private async #loadTypes () { const res = await this.dbh.selectRow( SQL.loadTypes ); if ( !res.ok ) throw res; const types = res.data?.acl || {}; for ( const type of Object.values( types ) ) { if ( !type.enabled ) { delete types[ type.type ]; } else { for ( const role of Object.values( type.roles ) ) { if ( !role.enabled ) { delete type.roles[ role.role ]; } else { role.name = this.config.types[ type.type ]?.roles[ role.role ].name; role.description = this.config.types[ type.type ]?.roles[ role.role ].description; } } type.notifications ||= {}; for ( const notification of Object.values( type.notifications ) ) { if ( !notification.enabled ) { delete type.notifications[ notification.notification ]; } else { notification.name = this.config.types[ type.type ]?.notifications[ notification.notification ].name; notification.description = this.config.types[ type.type ]?.notifications[ notification.notification ].description; notification.channels ??= {}; for ( const channel of [ "internal", "email", "telegram", "push" ] ) { notification.channels[ channel ] ??= {}; notification.channels[ channel ].channel ??= channel; if ( this.app.notifications.channels[ channel ]?.enabled ) { notification.channels[ channel ].enabled ??= true; } else { notification.channels[ channel ].enabled = false; } if ( notification.channels[ channel ].enabled ) { notification.channels[ channel ].editable ??= this.app.notifications.channels[ channel ].editable ?? true; notification.channels[ channel ].subscribed ??= this.app.notifications.channels[ channel ].subscribed ?? true; } else { notification.channels[ channel ].editable = false; notification.channels[ channel ].subscribed = false; } } } } } } this.#types = types; return result( 200 ); } async #resolveAclId ( aclObjectId, aclResolver ) { const resolver = this.app.api.acl.resolvers[ aclResolver ]; if ( !resolver ) return aclObjectId; const cacheId = `${ aclResolver }/${ aclObjectId }`; var aclId = this.#aclObjectIdCache.get( cacheId ); if ( aclId ) return aclId; const mutex = this.#mutexSet.get( `resolve/${ cacheId }` ); if ( !mutex.tryLock() ) return mutex.wait(); const res = await this.dbh.selectRow( resolver, [ aclObjectId ] ); if ( !res.ok ) { aclId = false; } else if ( res.data ) { aclId = res.data.id; this.#aclObjectIdCache.set( cacheId, aclId ); } else { aclId = null; } mutex.unlock( aclId ); return aclId; } async #getAclType ( aclId, { dbh } = {} ) { var aclType = this.#aclIdCache.get( aclId ); if ( aclType ) return aclType; const mutex = this.#mutexSet.get( `type/${ aclId }` ); if ( !mutex.tryLock() ) return mutex.wait(); dbh ||= this.dbh; const res = await dbh.selectRow( SQL.getAclType, [ aclId ] ); if ( !res.ok ) { aclType = false; } else if ( res.data ) { aclType = res.data.type; if ( this.#types[ aclType ] ) { this.#aclIdCache.set( aclId, aclType ); } } else { aclType = null; } mutex.unlock( aclType ); return aclType; } async #setAclUserRoles ( aclId, userId, roles, { parentUserId, dbh } = {} ) { var res = this.#validateUser( userId, parentUserId ); if ( !res.ok ) return res; dbh ||= this.dbh; const user = await this.getAclUser( aclId, userId, { dbh } ); if ( !user ) { return result( [ 400, "Unable to delete ACL user roles" ] ); } const aclType = user.aclType; // check parent user if ( parentUserId ) { const res = await this.#getParentUserPermissions( aclId, parentUserId ); if ( !res.ok ) return res; var parentUserPermissions = res.data; } if ( !roles ) { roles = []; } else if ( !Array.isArray( roles ) ) { roles = [ roles ]; } roles = new Set( roles ); const addRoles = [], deleteRoles = []; // add roles for ( const role of roles ) { // invalid role if ( !this.#types[ aclType ].roles[ role ] ) return result( [ 400, `ACL role "${ role }" is invalid` ] ); // parent user can't add user role if ( parentUserPermissions && !parentUserPermissions.hasAll( this.#types[ aclType ].roles[ role ].permissions ) ) { return result( [ 400, `ACL role ${ role } is invalid` ] ); } if ( !user.hasRoles( role ) ) addRoles.push( role ); } // delete roles if ( user.roles ) { for ( const role of user.roles ) { if ( roles.has( role ) ) continue; // parent user can't remove user role if ( parentUserPermissions && !parentUserPermissions.permissions.hasAll( this.#types[ aclType ].roles[ role ].permissions ) ) { return result( [ 400, `ACL role ${ role } is invalid` ] ); } deleteRoles.push( role ); } } if ( !addRoles.length && !deleteRoles.length ) return result( 200 ); res = await dbh.begin( async dbh => { var res; if ( addRoles.length ) { res = await dbh.do( sql`INSERT INTO acl_user_role`.VALUES( addRoles.map( role => { return { "acl_id": aclId, "user_id": userId, "acl_role_id": sql`SELECT acl_role.id FROM acl_role, acl_type WHERE acl_role.acl_type_id = acl_type.id AND acl_type.type = ${ aclType } AND acl_role.role = ${ role }`, }; } ) ) ); if ( !res.ok ) throw res; } if ( deleteRoles.length ) { res = await dbh.do( sql` DELETE FROM acl_user_role WHERE acl_id = ${ aclId } AND user_id = ${ userId } AND acl_role_id IN ( SELECT acl_role.id FROM acl_role, acl_type WHERE acl_role.acl_type_id = acl_type.id AND acl_type.type = ${ aclType } AND acl_role.role`.IN( deleteRoles ).sql` ) ` ); if ( !res.ok ) throw res; } } ); return res; } #validateUser ( userId, parentUserId ) { if ( this.app.userIsRoot( userId ) ) return result( [ 400, "Unable to add root user" ] ); if ( userId === parentUserId ) return result( [ 400, "Unable to add user" ] ); return result( 200 ); } async #sendAclNotification ( aclId, notificationType, subject, body, options = {} ) { const type = await this.#getAclType( aclId ); if ( !type ) return result( [ 400, "Invalid ACL id" ] ); const notification = this.#types[ type ]?.notifications?.[ notificationType ]; if ( !notification ) return result( [ 400, "Notification is invalid" ] ); var res; if ( notification.roles ) { const roles = notification.roles.map( role => this.#types[ type ].roles[ role ].id ); res = await this.dbh.select( SQL.getAclNotificationRoleUsers, [ aclId, roles, notification.id ] ); } else { res = await this.dbh.select( SQL.getAclNotificationUsers, [ notification.id, aclId ] ); } if ( !res.ok ) return res; const users = res.data; if ( !users ) return result( 200 ); const channelUsers = {}; for ( const user of users ) { for ( const channel of Object.values( notification.channels ) ) { if ( !channel.enabled ) continue; const subscribed = channel.editable ? ( user[ channel.channel ] ?? channel.subscribed ) : channel.subscribed; if ( !subscribed ) continue; channelUsers[ channel.channel ] ??= []; channelUsers[ channel.channel ].push( user.id ); } } const promises = []; // internal if ( channelUsers.internal?.length ) { promises.push( this.app.notifications.sendInternalNotification( channelUsers.internal, subject, body, options.internal ) ); } // email if ( channelUsers.email?.length ) { promises.push( this.app.notifications.sendEmailNotification( channelUsers.email, subject, body, options.email ) ); } // telegram if ( channelUsers.telegram?.length ) { promises