From bfff2d20f9009aee707c0736e9cb842ea4e5afd5 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:53:15 -0500 Subject: [PATCH] dev(backend): add /auth/request-app-root-dir Add the ability to request an app's root directory. A permission rewriter is provided so that apps may refer to a permission when requesting this access without knowing the path of the app's root directory. --- .../src/modules/data-access/AppService.js | 93 +++++++++++++++- .../src/routers/auth/request-app-root-dir.js | 105 ++++++++++++++++++ .../src/services/PermissionAPIService.js | 1 + .../src/services/auth/PermissionService.js | 40 ++++++- .../src/services/auth/permissionUtils.mjs | 20 ++++ 5 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/backend/src/routers/auth/request-app-root-dir.js diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 24609b00f..9a5745278 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -2,9 +2,10 @@ import { v4 as uuidv4 } from 'uuid'; import APIError from '../../api/APIError.js'; import config from '../../config.js'; -import { app_name_exists } from '../../helpers.js'; +import { NodeInternalIDSelector } from '../../filesystem/node/selectors.js'; +import { app_name_exists, get_app } from '../../helpers.js'; import { AppUnderUserActorType, UserActorType } from '../../services/auth/Actor.js'; -import { PermissionUtil } from '../../services/auth/permissionUtils.mjs'; +import { PERMISSION_FOR_NOTHING_IN_PARTICULAR, PermissionRewriter, PermissionUtil } from '../../services/auth/permissionUtils.mjs'; import BaseService from '../../services/BaseService.js'; import { DB_READ, DB_WRITE } from '../../services/database/consts.js'; import { Context } from '../../util/context.js'; @@ -197,6 +198,59 @@ export default class AppService extends BaseService { this.repository = new AppRepository(); this.db = this.services.get('database').get(DB_READ, 'apps'); this.db_write = this.services.get('database').get(DB_WRITE, 'apps'); + + const svc_permission = this.services.get('permission'); + const svc_app = this; + + // Rewrite app-root-dir:: to fs:: + svc_permission.register_rewriter(PermissionRewriter.create({ + matcher: permission => permission.startsWith('app-root-dir:'), + rewriter: async permission => { + const context = Context.get(); + + // Only "AppUnderUser" scope is allowed to have this permission rewritten to + // an actual filesystem permission - this is because apps will still be limited + // baesd on a user's own access. + const actor = context.get('actor'); + if ( ! Context.get('is_grant_user_app_permission') ) { + return PERMISSION_FOR_NOTHING_IN_PARTICULAR; + } + + const parts = PermissionUtil.split(permission); + if ( parts.length < 3 ) { + throw APIError.create('field_invalid', null, { key: 'permission', got: permission }); + } + + // <>:: + const target_app_uid = parts[1]; + const access = parts[2]; + if ( ! target_app_uid ) { + throw APIError.create('field_invalid', null, { key: 'target_app_uid', got: target_app_uid }); + } + + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + const target_app = await get_app({ uid: target_app_uid }); + if ( ! target_app ) { + throw APIError.create('entity_not_found', null, { identifier: `app:${target_app_uid}` }); + } + if ( target_app.owner_user_id !== actor.type.user.id ) { + throw APIError.create('forbidden'); + } + + const root_dir_id = await svc_app.getAppRootDirId(target_app); + const svc_fs = context.get('services').get('filesystem'); + const node = await svc_fs.node(new NodeInternalIDSelector('mysql', root_dir_id)); + await node.fetchEntry(); + if ( ! node.found ) throw APIError.create('subject_does_not_exist'); + + const node_uid = await node.get('uid'); + return PermissionUtil.join('fs', node_uid, access); + }, + })); + } static PROTECTED_FIELDS = ['last_review']; @@ -953,6 +1007,41 @@ export default class AppService extends BaseService { } } + /** + * Resolves an app's subdomain to its puter.site root_dir_id. + * Tries associated_app_id first, then falls back to index_url-based lookup. + * @param {Object} app - App object with id, index_url, uid + * @returns {Promise} root_dir_id + * @throws {APIError} entity_not_found if the app has no subdomain / root directory + */ + async getAppRootDirId (app) { + const db_sites = this.services.get('database').get(DB_READ, 'sites'); + const rows = await db_sites.read( + 'SELECT root_dir_id FROM subdomains WHERE associated_app_id = ? AND root_dir_id IS NOT NULL LIMIT 1', + [app.id], + ); + if ( rows?.[0]?.root_dir_id != null ) { + return rows[0].root_dir_id; + } + + let hostname; + try { + hostname = (new URL(app.index_url)).hostname.toLowerCase(); + } catch { + throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` }); + } + const hosting_domain = config.static_hosting_domain?.toLowerCase(); + if ( !hosting_domain || !hostname.endsWith(`.${hosting_domain}`) ) { + throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` }); + } + const subdomain = hostname.slice(0, hostname.length - hosting_domain.length - 1); + const site = await this.services.get('puter-site').get_subdomain(subdomain, { is_custom_domain: false }); + if ( ! site?.root_dir_id ) { + throw APIError.create('entity_not_found', null, { identifier: `app ${app.uid} root directory` }); + } + return site.root_dir_id; + } + async #ensure_puter_site_subdomain_is_owned (index_url, user) { if ( ! user ) return; diff --git a/src/backend/src/routers/auth/request-app-root-dir.js b/src/backend/src/routers/auth/request-app-root-dir.js new file mode 100644 index 000000000..d86b83a3a --- /dev/null +++ b/src/backend/src/routers/auth/request-app-root-dir.js @@ -0,0 +1,105 @@ +/* + * 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 . + */ +const eggspress = require('../../api/eggspress'); +const APIError = require('../../api/APIError'); +const { AppUnderUserActorType } = require('../../services/auth/Actor'); +const { Context } = require('../../util/context'); +const { validate_fields } = require('../../util/validutil'); +const { get_app } = require('../../helpers'); +const { NodeInternalIDSelector } = require('../../filesystem/node/selectors'); +const { HLStat } = require('../../filesystem/hl_operations/hl_stat'); +const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); +const { quot } = require('@heyputer/putility').libs.string; + +module.exports = eggspress('/auth/request-app-root-dir', { + subdomain: 'api', + auth2: true, + allowedMethods: ['POST'], +}, async (req, res) => { + const context = Context.get(); + const actor = context.get('actor'); + + if ( ! (actor.type instanceof AppUnderUserActorType) ) { + throw APIError.create('forbidden', null, { debug_reason: 'not app actor' }); + } + + validate_fields({ + app_uid: { type: 'string', optional: false }, + access: { type: 'string', optional: false }, + }, req.body); + + const { app_uid: target_app_uid, access } = req.body; + if ( access !== 'read' && access !== 'write' ) { + throw APIError.create('field_invalid', null, { + key: 'access', + expected: "'read' or 'write'", + got: access, + }); + } + + if ( ! target_app_uid ) { + throw APIError.create('field_invalid', null, { + key: 'resource_request_code', + expected: 'app_uid', + got: target_app_uid, + }); + } + + const target_app = await get_app({ uid: target_app_uid }); + if ( ! target_app ) { + throw APIError.create('entity_not_found', null, { identifier: `app:${target_app_uid}` }); + } + + if ( target_app.owner_user_id !== actor.type.user.id ) { + throw APIError.create('forbidden', null, { + debug_reason: 'Expected to match: ' + + `${quot(target_app.owner_user_id)} and ${quot(actor.type.user.id)}`, + }); + } + + const svc_app = context.get('services').get('app'); + const root_dir_id = await svc_app.getAppRootDirId(target_app); + const svc_fs = context.get('services').get('filesystem'); + const node = await svc_fs.node(new NodeInternalIDSelector('mysql', root_dir_id)); + await node.fetchEntry(); + if ( ! node.found ) { + throw APIError.create('subject_does_not_exist'); + } + + const node_uid = await node.get('uid'); + const fs_perm = PermissionUtil.join('fs', node_uid, access); + const svc_permission = context.get('services').get('permission'); + const has_perm = await svc_permission.check(actor, fs_perm); + if ( ! has_perm ) { + throw APIError.create('permission_denied', null, { permission: fs_perm }); + } + + const hl_stat = new HLStat(); + const stat_result = await hl_stat.run({ + subject: node, + user: actor.type.user, + return_subdomains: false, + return_permissions: false, + return_shares: false, + return_versions: false, + return_size: true, + }); + + res.json(stat_result); +}); diff --git a/src/backend/src/services/PermissionAPIService.js b/src/backend/src/services/PermissionAPIService.js index 23c820370..1e68d7b01 100644 --- a/src/backend/src/services/PermissionAPIService.js +++ b/src/backend/src/services/PermissionAPIService.js @@ -53,6 +53,7 @@ class PermissionAPIService extends BaseService { app.use(require('../routers/auth/revoke-user-group')); app.use(require('../routers/auth/list-permissions').default); app.use(require('../routers/auth/check-permissions.js')); + app.use(require('../routers/auth/request-app-root-dir')); Endpoint(require('../routers/auth/check-app-acl.endpoint.js')).but({ route: '/auth/check-app-acl', diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js index ea5820889..59ac6f56d 100644 --- a/src/backend/src/services/auth/PermissionService.js +++ b/src/backend/src/services/auth/PermissionService.js @@ -29,6 +29,7 @@ const { PERM_KEY_PREFIX, MANAGE_PERM_PREFIX } = require('./permissionConts.mjs') const { PermissionUtil, PermissionExploder, PermissionImplicator, PermissionRewriter } = require('./permissionUtils.mjs'); const { spanify } = require('../../util/otelutil'); const { redisClient } = require('../../clients/redis/redisSingleton'); +const { Context } = require('../../util/context'); /** * @class PermissionService @@ -255,6 +256,28 @@ class PermissionService extends BaseService { } } + /** + * Removes permission-scan cache entries for a single app-under-user actor. + * Used after grant_user_app_permission so the next check sees the new permission + * without waiting for cache TTL. Only keys for this (user, app) are removed. + * + * @param {string} user_uuid - The user's UUID. + * @param {string} app_uid - The app UID. + * @returns {Promise} + */ + async invalidate_permission_scan_cache_for_app_under_user (user_uuid, app_uid) { + const prefix = PermissionUtil.permission_scan_cache_prefix_for_app_under_user(user_uuid, app_uid); + const pattern = `${prefix}*`; + let cursor = '0'; + const toDelete = []; + do { + const [next_cursor, keys] = await redisClient.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = next_cursor; + if ( keys?.length ) toDelete.push(...keys); + } while ( cursor !== '0' ); + if ( toDelete.length ) await redisClient.del(...toDelete); + } + async validateUserPerms ({ actor, permissions }) { const flatPermsReading = await this.#flat_validateUserPerms({ actor, permissions }); @@ -395,7 +418,11 @@ class PermissionService extends BaseService { * @returns {Promise} */ async grant_user_app_permission (actor, app_uid, permission, extra = {}, meta) { - permission = await this._rewrite_permission(permission); + // We add 'is_grant_user_app_permission' to guard against any logic + // error that might cause unintended access being granted to users. + permission = await Context.sub({ + is_grant_user_app_permission: true, + }).arun(async () => await this._rewrite_permission(permission)); let app = await get_app({ uid: app_uid }); if ( ! app ) app = await get_app({ name: app_uid }); @@ -440,6 +467,9 @@ class PermissionService extends BaseService { this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` + `VALUES (${sql_vals})`, Object.values(audit_values)); + + // Invalidate permission-scan cache for this app-under-user so the next check sees the grant. + await this.invalidate_permission_scan_cache_for_app_under_user(actor.type.user.uuid, app.uid); } /** @@ -1018,7 +1048,7 @@ class PermissionService extends BaseService { async list_user_permission_issuers (user) { const rows = await this.db.read('SELECT DISTINCT issuer_user_id FROM `user_to_user_permissions` ' + 'WHERE `holder_user_id` = ?', - [ user.id ]); + [user.id]); const users = []; for ( const row of rows ) { @@ -1201,7 +1231,7 @@ class PermissionService extends BaseService { { id: 'grant-user-app', handler: async (args, _log) => { - const [ username, app_uid, permission, extra ] = args; + const [username, app_uid, permission, extra] = args; // actor from username const actor = new Actor({ @@ -1216,7 +1246,7 @@ class PermissionService extends BaseService { { id: 'scan', handler: async (args, ctx) => { - const [ username, permission ] = args; + const [username, permission] = args; // actor from username const actor = new Actor({ @@ -1233,7 +1263,7 @@ class PermissionService extends BaseService { { id: 'scan-app', handler: async (args, ctx) => { - const [ username, app_name, permission ] = args; + const [username, app_name, permission] = args; const app = await get_app({ name: app_name }); // actor from username diff --git a/src/backend/src/services/auth/permissionUtils.mjs b/src/backend/src/services/auth/permissionUtils.mjs index 3ec47a8f3..25d93e6f1 100644 --- a/src/backend/src/services/auth/permissionUtils.mjs +++ b/src/backend/src/services/auth/permissionUtils.mjs @@ -1,5 +1,10 @@ import { MANAGE_PERM_PREFIX } from './permissionConts.mjs'; +/** + * De-facto placeholder permission for permission rewrites that do not grant any access. + */ +export const PERMISSION_FOR_NOTHING_IN_PARTICULAR = 'permission-for-nothing-in-particular'; + /** * The PermissionUtil class provides utility methods for handling * permission strings and operations, including splitting, joining, @@ -92,6 +97,21 @@ export const PermissionUtil = { return `permission-scan:*${token_uid}:options-list:*`; }, + /** + * Exact key prefix for permission-scan cache entries belonging to a given app-under-user actor. + * Cache keys are built as join('permission-scan', actor.uid, 'options-list', ...); + * for app-under-user, actor.uid is 'app-under-user:{user_uuid}:{app_uid}' (colon-escaped in the key). + * Use with Redis SCAN MATCH prefix + '*' to delete only that actor's cache entries. + * + * @param {string} user_uuid - The user's UUID. + * @param {string} app_uid - The app UID. + * @returns {string} The exact key prefix for that actor's permission-scan cache keys. + */ + permission_scan_cache_prefix_for_app_under_user (user_uuid, app_uid) { + const actor_uid = `app-under-user:${user_uuid}:${app_uid}`; + return this.join('permission-scan', actor_uid, 'options-list'); + }, + /** * Converts a permission reading structure into an array of option objects. * Recursively traverses the reading tree to collect all options with their associated path and data.