mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-06 01:20:41 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user