Spaces:
Sleeping
Sleeping
| /* | |
| * Copyright (C) 2024-present Puter Technologies Inc. | |
| * | |
| * This file is part of Puter. | |
| * | |
| * Puter is free software: you can redistribute it and/or modify | |
| * it under the terms of the GNU Affero General Public License as published | |
| * by the Free Software Foundation, either version 3 of the License, or | |
| * (at your option) any later version. | |
| * | |
| * This program is distributed in the hope that it will be useful, | |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| * GNU Affero General Public License for more details. | |
| * | |
| * You should have received a copy of the GNU Affero General Public License | |
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| */ | |
| const APIError = require('../../api/APIError'); | |
| const FSNodeParam = require('../../api/filesystem/FSNodeParam'); | |
| const { NodePathSelector } = require('../../filesystem/node/selectors'); | |
| const { get_user } = require('../../helpers'); | |
| const configurable_auth = require('../../middleware/configurable_auth'); | |
| const { Context } = require('../../util/context'); | |
| const { Endpoint } = require('../../util/expressutil'); | |
| const BaseService = require('../BaseService'); | |
| const { AppUnderUserActorType, UserActorType, Actor, SystemActorType, AccessTokenActorType } = require('./Actor'); | |
| const { MANAGE_PERM_PREFIX } = require('./permissionConts.mjs'); | |
| const { PermissionUtil } = require('./permissionUtils.mjs'); | |
| /** | |
| * ACLService class handles Access Control List functionality for the Puter filesystem. | |
| * Extends BaseService to provide permission management, access control checks, and ACL operations. | |
| * Manages user-to-user permissions, filesystem node access, and handles special cases like | |
| * public folders, app data access, and system actor privileges. Provides methods for | |
| * checking permissions, setting ACLs, and managing access control hierarchies. | |
| * @extends BaseService | |
| */ | |
| class ACLService extends BaseService { | |
| static MODULES = { | |
| express: require('express'), | |
| }; | |
| /** | |
| * Initializes the ACLService by registering the 'public-folders' feature flag | |
| * with the feature flag service. The flag's value is determined by the | |
| * global_config.enable_public_folders setting. | |
| * | |
| * @async | |
| * @private | |
| * @returns {Promise<void>} | |
| */ | |
| async _init () { | |
| const svc_featureFlag = this.services.get('feature-flag'); | |
| svc_featureFlag.register('public-folders', { | |
| $: 'config-flag', | |
| value: this.global_config.enable_public_folders ?? false, | |
| }); | |
| } | |
| /** | |
| * Checks if an actor has permission to perform a specific mode of access on a resource | |
| * | |
| * @param {Actor} actor - The actor requesting access (user, system, app, etc) | |
| * @param {FSNode} resource - The filesystem resource being accessed | |
| * @param {('see'| 'list'| 'read'| 'write')} mode - The access mode being requested ('read', 'write', etc) | |
| * @returns {Promise<boolean>} True if access is allowed, false otherwise | |
| */ | |
| async check (actor, resource, mode) { | |
| const ld = (Context.get('logdent') ?? 0) + 1; | |
| /** | |
| * Checks if an actor has permission for a specific mode on a resource | |
| * | |
| * @param {Actor} actor - The actor requesting permission | |
| * @param {FSNode} resource - The filesystem resource to check permissions for | |
| * @param {('see'| 'list'| 'read'| 'write' | 'manage')} mode - The permission mode to check ('see', 'list', 'read', 'write', 'manage') | |
| * @returns {Promise<boolean>} True if actor has permission, false otherwise | |
| */ | |
| return await Context.get().sub({ logdent: ld }).arun(async () => { | |
| const result = await this._check_fsNode(actor, resource, mode); | |
| if ( this.verbose ) { | |
| console.log('LOGGING ACL CHECK', { | |
| actor, | |
| mode, | |
| // trace: (new Error()).stack, | |
| result, | |
| }); | |
| } | |
| return result; | |
| }); | |
| } | |
| /** | |
| * Checks if an actor has permission for a specific mode on a filesystem node. | |
| * Handles various actor types (System, User, AppUnderUser, AccessToken) and | |
| * enforces access control rules including public folder access and app data permissions. | |
| * | |
| * @param {Actor} actor - The actor requesting access | |
| * @param {FSNode} fsNode - The filesystem node to check permissions on | |
| * @param {string} mode - The permission mode to check ('see', 'list', 'read', 'write') | |
| * @returns {Promise<boolean>} True if actor has permission, false otherwise | |
| * @private | |
| */ | |
| async ['__on_install.routes'] (_, { app }) { | |
| /** | |
| * Handles route installation for ACL service endpoints. | |
| * Sets up routes for user-to-user permission management including: | |
| * - /acl/stat-user-user: Get permissions between users | |
| * - /acl/set-user-user: Set permissions between users | |
| * | |
| * @param {*} _ Unused parameter | |
| * @param {Object} options Installation options | |
| * @param {Express} options.app Express app instance to attach routes to | |
| * @returns {Promise<void>} | |
| */ | |
| const r_acl = (() => { | |
| const require = this.require; | |
| const express = require('express'); | |
| return express.Router(); | |
| })(); | |
| app.use('/acl', r_acl); | |
| Endpoint({ | |
| route: '/stat-user-user', | |
| methods: ['POST'], | |
| mw: [configurable_auth()], | |
| handler: async (req, res) => { | |
| // Only user actor is allowed | |
| if ( ! (req.actor.type instanceof UserActorType) ) { | |
| return res.status(403).json({ | |
| error: 'forbidden', | |
| }); | |
| } | |
| const holder_user = await get_user({ | |
| username: req.body.user, | |
| }); | |
| if ( ! holder_user ) { | |
| throw APIError.create('user_does_not_exist', null, { | |
| username: req.body.user, | |
| }); | |
| } | |
| const issuer = req.actor; | |
| const holder = new Actor({ | |
| type: new UserActorType({ | |
| user: holder_user, | |
| }), | |
| }); | |
| const node = await (new FSNodeParam('path')).consolidate({ | |
| req, | |
| getParam: () => req.body.resource, | |
| }); | |
| const permissions = await this.stat_user_user(issuer, holder, node); | |
| res.json({ permissions }); | |
| }, | |
| }).attach(r_acl); | |
| Endpoint({ | |
| route: '/set-user-user', | |
| methods: ['POST'], | |
| mw: [configurable_auth()], | |
| handler: async (req, res) => { | |
| // Only user actor is allowed | |
| if ( ! (req.actor.type instanceof UserActorType) ) { | |
| return res.status(403).json({ | |
| error: 'forbidden', | |
| }); | |
| } | |
| const holder_user = await get_user({ | |
| username: req.body.user, | |
| }); | |
| if ( ! holder_user ) { | |
| throw APIError.create('user_does_not_exist', null, { | |
| username: req.body.user, | |
| }); | |
| } | |
| const issuer = req.actor; | |
| const holder = new Actor({ | |
| type: new UserActorType({ | |
| user: holder_user, | |
| }), | |
| }); | |
| const node = await (new FSNodeParam('path')).consolidate({ | |
| req, | |
| getParam: () => req.body.resource, | |
| }); | |
| await this.set_user_user(issuer, holder, node, req.body.mode, req.body.options ?? {}); | |
| res.json({}); | |
| }, | |
| }).attach(r_acl); | |
| } | |
| /** | |
| * Sets user-to-user permissions for a filesystem resource | |
| * @param {Actor} issuer - The user granting the permission | |
| * @param {Actor|string} holder - The user receiving the permission, or their username | |
| * @param {FSNode|string} resource - The filesystem resource or permission string | |
| * @param {string} mode - The permission mode to set | |
| * @param {Object} [options={}] - Additional options | |
| * @param {boolean} [options.only_if_higher] - Only set permission if no higher mode exists | |
| * @returns {Promise<boolean>} False if permission already exists or higher mode present | |
| * @throws {Error} If issuer or holder is not a UserActorType | |
| */ | |
| async set_user_user (issuer, holder, resource, mode, options = {}) { | |
| const svc_perm = this.services.get('permission'); | |
| const svc_fs = this.services.get('filesystem'); | |
| if ( typeof holder === 'string' ) { | |
| const holder_user = await get_user({ username: holder }); | |
| if ( ! holder_user ) { | |
| throw APIError.create('user_does_not_exist', null, { username: holder }); | |
| } | |
| holder = new Actor({ | |
| type: new UserActorType({ user: holder_user }), | |
| }); | |
| } | |
| let uid; | |
| if ( typeof resource === 'string' && mode === undefined ) { | |
| const perm_parts = PermissionUtil.split(resource); | |
| const isManage = PermissionUtil.isManage(resource); | |
| uid = perm_parts.at(isManage ? -1 : -2); // always will end with fs:uid:mode | |
| mode = isManage ? MANAGE_PERM_PREFIX : perm_parts.at(-1); | |
| resource = await svc_fs.node(new NodePathSelector(uid)); | |
| if ( ! resource ) { | |
| throw APIError.create('subject_does_not_exist'); | |
| } | |
| } | |
| if ( ! (issuer.type instanceof UserActorType) ) { | |
| throw new Error('issuer must be a UserActorType'); | |
| } | |
| if ( ! (holder.type instanceof UserActorType) ) { | |
| throw new Error('holder must be a UserActorType'); | |
| } | |
| const stat = await this.stat_user_user(issuer, holder, resource); | |
| const perms_on_this = stat[await resource.get('path')] ?? []; | |
| const mode_parts = perms_on_this.map(perm => PermissionUtil.isManage(perm) ? MANAGE_PERM_PREFIX : PermissionUtil.split(perm).at(-1)); | |
| // If mode already present, do nothing | |
| if ( mode_parts.includes(mode) ) { | |
| return false; | |
| } | |
| // If higher mode already present, do nothing | |
| if ( options.only_if_higher ) { | |
| const higher_modes = this._higher_modes(mode); | |
| if ( mode_parts.some(m => m === MANAGE_PERM_PREFIX || higher_modes.includes(m)) ) { | |
| return false; | |
| } | |
| } | |
| uid = uid ?? await resource.get('uid'); | |
| // If mode not present, add it | |
| await svc_perm.grant_user_user_permission(issuer, holder.type.user.username, mode === MANAGE_PERM_PREFIX ? PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', uid) : PermissionUtil.join('fs', uid, mode)); | |
| // Remove other modes | |
| for ( const perm of perms_on_this ) { | |
| const existingPermMode = PermissionUtil.isManage(perm) ? MANAGE_PERM_PREFIX : PermissionUtil.split(perm).at(-1); | |
| if ( existingPermMode === mode ) continue; | |
| await svc_perm.revoke_user_user_permission(issuer, holder.type.user.username, perm); | |
| } | |
| } | |
| /** | |
| * Sets user-to-user permissions for a filesystem resource | |
| * @param {Actor} issuer - The user granting the permission | |
| * @param {Actor|string} holder - The user receiving the permission, or their username | |
| * @param {FSNode|string} resource - The filesystem resource or permission string | |
| * @param {string} mode - The permission mode to set | |
| * @param {Object} [options={}] - Additional options | |
| * @param {boolean} [options.only_if_higher] - Only set permission if no higher mode exists | |
| * @returns {Promise<boolean>} False if permission already exists or higher mode present | |
| * @throws {Error} If issuer or holder is not a UserActorType | |
| */ | |
| async stat_user_user (issuer, holder, resource) { | |
| const svc_perm = this.services.get('permission'); | |
| if ( ! (issuer.type instanceof UserActorType) ) { | |
| throw new Error('issuer must be a UserActorType'); | |
| } | |
| if ( ! (holder.type instanceof UserActorType) ) { | |
| throw new Error('holder must be a UserActorType'); | |
| } | |
| const permissions = {}; | |
| let perm_fsNode = resource; | |
| while ( !await perm_fsNode.get('is-root') ) { | |
| const prefix = PermissionUtil.join('fs', await perm_fsNode.get('uid')); | |
| const these_permissions = await | |
| svc_perm.query_issuer_holder_permissions_by_prefix(issuer, holder, prefix); | |
| if ( these_permissions.length > 0 ) { | |
| permissions[await perm_fsNode.get('path')] = these_permissions; | |
| } | |
| perm_fsNode = await perm_fsNode.getParent(); | |
| } | |
| return permissions; | |
| } | |
| /** | |
| * Checks filesystem node permissions for a given actor and mode | |
| * | |
| * @param {Actor} actor - The actor requesting access (User, System, AccessToken, or AppUnderUser) | |
| * @param {FSNode} fsNode - The filesystem node to check permissions for | |
| * @param {'see'| 'list' | 'read' | 'write' | 'manage'} mode - The permission mode to check ('see', 'list', 'read', 'write', 'manage) | |
| * @returns {Promise<boolean>} True if actor has permission, false otherwise | |
| * | |
| * @description | |
| * Evaluates access permissions by checking: | |
| * - System actors always have access | |
| * - Public folder access rules | |
| * - Access token authorizer permissions | |
| * - App data directory special cases | |
| * - Explicit permissions in the ACL hierarchy | |
| */ | |
| async _check_fsNode (actor, fsNode, mode) { | |
| const context = Context.get(); | |
| actor = Actor.adapt(actor); | |
| if ( actor.type instanceof SystemActorType ) { | |
| return true; | |
| } | |
| const path_selector = fsNode.get_selector_of_type(NodePathSelector); | |
| if ( path_selector && path_selector.value === '/' ) { | |
| if ( ['list', 'see', 'read'].includes(mode) ) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| // PERF: Short-circuit the permission check for users accessing their own files. | |
| // Since the filesystem structure guarantees ownership within a user's home directory, | |
| // we can safely grant access without a database lookup for the fsentry. | |
| if ( actor.type instanceof UserActorType ) { | |
| const username = actor.type.user.username; | |
| const path_selector = fsNode.get_selector_of_type(NodePathSelector); | |
| if ( path_selector ) { | |
| const path = path_selector.value; | |
| // If the path starts with the user's own home directory, grant access immediately. | |
| if ( path === `/${username}` || path.startsWith(`/${username}/`) ) { | |
| return true; | |
| } | |
| } | |
| } | |
| // PERF: Short-circuit for apps accessing their own AppData directory. | |
| if ( actor.type instanceof AppUnderUserActorType ) { | |
| const username = actor.type.user.username; | |
| const app_uid = actor.type.app.uid; | |
| const path_selector = fsNode.get_selector_of_type(NodePathSelector); | |
| if ( path_selector ) { | |
| const path = path_selector.value; | |
| const appDataPath = `/${username}/AppData/${app_uid}`; | |
| if ( path === appDataPath || path.startsWith(`${appDataPath}/`) ) { | |
| return true; | |
| } | |
| } | |
| } | |
| // Hard rule: anyone and anything can read /user/public directories | |
| if ( this.global_config.enable_public_folders ) { | |
| const public_modes = Object.freeze(['read', 'list', 'see']); | |
| let is_public; | |
| /** | |
| * Checks if a given mode is allowed for a public folder path | |
| * | |
| * @param {Actor} actor - The actor requesting access | |
| * @param {FSNode} fsNode - The filesystem node to check | |
| * @param {string} mode - The access mode being requested (read/write/etc) | |
| * @returns {Promise<boolean>} True if access is allowed, false otherwise | |
| * | |
| * Handles special case for /user/public directories when public folders are enabled. | |
| * Only allows read, list, and see modes for public folders, and only if the folder | |
| * owner has confirmed their email (except for admin user). | |
| */ | |
| await (async () => { | |
| if ( ! public_modes.includes(mode) ) return; | |
| if ( ! (await fsNode.isPublic()) ) return; | |
| const svc_getUser = this.services.get('get-user'); | |
| const username = await fsNode.getUserPart(); | |
| const user = await svc_getUser.get_user({ username }); | |
| if ( ! (user.email_confirmed || user.username === 'admin') ) { | |
| return; | |
| } | |
| is_public = true; | |
| })(); | |
| if ( is_public ) return true; | |
| } | |
| // Access tokens only work if the authorizer has permission | |
| if ( actor.type instanceof AccessTokenActorType ) { | |
| const authorizer = actor.type.authorizer; | |
| const authorizer_perm = await this._check_fsNode(authorizer, fsNode, mode); | |
| if ( ! authorizer_perm ) return false; | |
| } | |
| // Hard rule: if app-under-user is accessing appdata directory, allow | |
| if ( actor.type instanceof AppUnderUserActorType ) { | |
| const appdata_path = `/${actor.type.user.username}/AppData/${actor.type.app.uid}`; | |
| const svc_fs = await context.get('services').get('filesystem'); | |
| const appdata_node = await svc_fs.node(new NodePathSelector(appdata_path)); | |
| if ( | |
| await appdata_node.is(fsNode) || | |
| await appdata_node.is_above(fsNode) | |
| ) { | |
| this.log.debug('TRUE BECAUSE APPDATA'); | |
| return true; | |
| } | |
| } | |
| // app-under-user only works if the user also has permission | |
| if ( actor.type instanceof AppUnderUserActorType ) { | |
| const user_actor = new Actor({ | |
| type: new UserActorType({ user: actor.type.user }), | |
| }); | |
| const user_perm = await this._check_fsNode(user_actor, fsNode, mode); | |
| if ( ! user_perm ) return false; | |
| } | |
| // Hard rule: if app-under-user is accessing appdata directory | |
| // under a **different user**, allow, | |
| // IFF that appdata directory is shared with user | |
| // (by "user also has permission" check above) | |
| /** | |
| * Checks if an actor has permission to perform a specific mode of access on a filesystem node. | |
| * Handles various actor types (System, AccessToken, AppUnderUser) and special cases like | |
| * public folders and app data directories. | |
| * | |
| * @param {Actor} actor - The actor requesting access | |
| * @param {FSNode} fsNode - The filesystem node to check access for | |
| * @param {string} mode - The access mode to check ('see', 'list', 'read', 'write') | |
| * @returns {Promise<boolean>} True if access is allowed, false otherwise | |
| * @private | |
| */ | |
| if ( await (async () => { | |
| if ( ! (actor.type instanceof AppUnderUserActorType) ) { | |
| return false; | |
| } | |
| if ( await fsNode.getUserPart() === actor.type.user.username ) { | |
| return false; | |
| } | |
| const components = await fsNode.getPathComponents(); | |
| if ( components[1] !== 'AppData' ) return false; | |
| if ( components[2] !== actor.type.app.uid ) return false; | |
| return true; | |
| })() ) return true; | |
| /** | |
| * @type {import('../../services/auth/PermissionService').PermissionService} | |
| */ | |
| const svc_permission = await context.get('services').get('permission'); | |
| let perm_fsNode = fsNode; | |
| while ( !await perm_fsNode.get('is-root') ) { | |
| const uid = await perm_fsNode.get('uid'); | |
| const permissionsToCheck = [mode === MANAGE_PERM_PREFIX ? PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', uid) : PermissionUtil.join('fs', uid, mode)]; | |
| const reading = await svc_permission.scan(actor, permissionsToCheck); | |
| const options = PermissionUtil.reading_to_options(reading); | |
| if ( options.length > 0 ) { | |
| return true; | |
| } | |
| perm_fsNode = await perm_fsNode.getParent(); | |
| } | |
| return false; | |
| } | |
| /** | |
| * Gets a safe error message for ACL check failures | |
| * @param {Actor} actor - The actor attempting the operation | |
| * @param {FSNode} resource - The filesystem resource being accessed | |
| * @param {string} mode - The access mode being checked ('read', 'write', etc) | |
| * @returns {APIError} Returns 'subject_does_not_exist' if actor cannot see resource, | |
| * otherwise returns 'forbidden' error | |
| */ | |
| async get_safe_acl_error (actor, resource, _mode) { | |
| const can_see = await this.check(actor, resource, 'see'); | |
| if ( ! can_see ) { | |
| return APIError.create('subject_does_not_exist'); | |
| } | |
| return APIError.create('forbidden'); | |
| } | |
| // If any logic depends on knowledge of the highest ACL mode, it should use | |
| // this method in case a higher mode is added (ex: might add 'config' mode) | |
| /** | |
| * Gets the highest permission mode in the ACL system | |
| * | |
| * @returns {string} Returns 'write' as the highest permission mode | |
| * | |
| * @remarks | |
| * This method should be used by any logic that depends on knowing the highest ACL mode, | |
| * in case higher modes are added in the future (e.g. a potential 'config' mode). | |
| * Currently 'write' is the highest mode in the hierarchy: see > list > read > write | |
| */ | |
| get_highest_mode () { | |
| return 'write'; | |
| } | |
| // TODO: DRY: Also in FilesystemService | |
| _higher_modes (mode) { | |
| // If you want to X, you can do so with any of [...Y] | |
| if ( mode === 'see' ) return ['see', 'list', 'read', 'write']; | |
| if ( mode === 'list' ) return ['list', 'read', 'write']; | |
| if ( mode === 'read' ) return ['read', 'write']; | |
| if ( mode === 'write' ) return ['write']; | |
| } | |
| } | |
| module.exports = { | |
| ACLService, | |
| }; | |