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.
This commit is contained in:
KernelDeimos
2026-02-17 21:53:15 -05:00
committed by Eric Dubé
parent d21ed31d67
commit bfff2d20f9
5 changed files with 252 additions and 7 deletions
@@ -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:<app-uid>:<access> to fs:<uuid>:<access>
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 });
}
// <>:<app-uid>:<access>
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<number>} 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;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
@@ -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',
@@ -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<void>}
*/
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<void>}
*/
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
@@ -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.