feat: manage permission for user to user and dev to app file sharing (#1567)

* perf: move user-user perm checks to flat kv entries

* feat: manage permission for user to user and dev to app file sharing

* fix typings cuz macos sucks

* fix: mac os typecase

* fix: chore macOs typecase

* fix: bad join of permissions

* feat: add check-permissions endpoint for checking an users perms

* Add 'manage' to permission lists in the Sharing dialog

* fix: manage being allowed through our ACL

---------

Co-authored-by: Nariman Jelveh <nj@puter.com>
This commit is contained in:
Daniel Salazar
2025-10-07 13:46:00 -07:00
committed by GitHub
parent 6d35496252
commit d9c64a9378
27 changed files with 635 additions and 567 deletions

View File

@@ -13,7 +13,7 @@ export default defineConfig([
},
},
{
files: ['src/backend/**/*.{js,mjs,cjs}'],
files: ['src/backend/**/*.{js,mjs,cjs,ts}'],
languageOptions: { globals: globals.node },
rules: {
'no-unused-vars': ['error', {
@@ -63,7 +63,7 @@ export default defineConfig([
},
},
{
files: ['extensions/**/*.{js,mjs,cjs}'],
files: ['extensions/**/*.{js,mjs,cjs,ts}'],
languageOptions: {
globals: {
extension: 'readonly',

View File

@@ -36,80 +36,76 @@ const {
} = require('../../../src/backend/src/filesystem/node/selectors.js');
const { Context } = require('../../../src/backend/src/util/context.js');
class ShareTestService extends use.Service {
static MODULES = {
uuidv4: require('uuid').v4,
}
};
async _init () {
async _init() {
const svc_commands = this.services.get('commands');
this._register_commands(svc_commands);
this.scenarios = require('./data/sharetest_scenarios');
const svc_db = this.services.get('database');
this.db = svc_db.get(svc_db.DB_WRITE, 'share-test');
}
_register_commands (commands) {
_register_commands(commands) {
commands.registerCommands('share-test', [
{
id: 'start',
description: '',
handler: async (_, log) => {
const results = await this.runit();
for ( const result of results ) {
log.log(`=== ${result.title} ===`);
if ( ! result.report ) {
log.log(`\x1B[32;1mSUCCESS\x1B[0m`);
log.log('\x1B[32;1mSUCCESS\x1B[0m');
continue;
}
log.log(
`\x1B[31;1mSTOPPED\x1B[0m at ` +
`${result.report.step}: ` +
result.report.report.message,
);
log.log('\x1B[31;1mSTOPPED\x1B[0m at ' +
`${result.report.step}: ${
result.report.report.message}`);
}
}
}
},
},
]);
}
async runit () {
async runit() {
await this.teardown_();
await this.setup_();
const results = [];
for ( const scenario of this.scenarios ) {
if ( ! scenario.title ) {
scenario.title = scenario.sequence.map(
step => step.title).join('; ')
scenario.title = scenario.sequence.map(step => step.title).join('; ');
}
results.push({
title: scenario.title,
report: await this.run_scenario_(scenario)
report: await this.run_scenario_(scenario),
});
}
await this.teardown_();
return results;
}
async setup_ () {
async setup_() {
await this.create_test_user_('testuser_eric');
await this.create_test_user_('testuser_stan');
await this.create_test_user_('testuser_kyle');
await this.create_test_user_('testuser_kenny');
}
async run_scenario_ (scenario) {
async run_scenario_(scenario) {
let error;
// Run sequence
for ( const step of scenario.sequence ) {
const method = this[`__scenario:${step.call}`];
const user = await get_user({ username: step.as })
const user = await get_user({ username: step.as });
const actor = await Actor.create(UserActorType, { user });
const generated = { user, actor };
const report = await Context.get().sub({ user, actor })
@@ -123,51 +119,49 @@ class ShareTestService extends use.Service {
}
return error;
}
async teardown_ () {
async teardown_() {
await this.delete_test_user_('testuser_eric');
await this.delete_test_user_('testuser_stan');
await this.delete_test_user_('testuser_kyle');
await this.delete_test_user_('testuser_kenny');
}
async create_test_user_ (username) {
await this.db.write(
`
async create_test_user_(username) {
await this.db.write(`
INSERT INTO user (uuid, username, email, free_storage, password)
VALUES (?, ?, ?, ?, ?)
`,
[
this.modules.uuidv4(),
username,
username + '@example.com',
1024 * 1024 * 500, // 500 MiB
this.modules.uuidv4(),
],
);
[
this.modules.uuidv4(),
username,
`${username}@example.com`,
1024 * 1024 * 500, // 500 MiB
this.modules.uuidv4(),
]);
const user = await get_user({ username });
const svc_user = this.services.get('user');
await svc_user.generate_default_fsentries({ user });
invalidate_cached_user(user);
return user;
}
async delete_test_user_ (username) {
async delete_test_user_(username) {
const user = await get_user({ username });
if ( ! user ) return;
await deleteUser(user.id);
}
// API for scenarios
async ['__scenario:create-example-file'] (
async ['__scenario:create-example-file'](
{ actor, user },
{ name, contents },
) {
const svc_fs = this.services.get('filesystem');
const parent = await svc_fs.node(new NodePathSelector(
`/${user.username}/Desktop`
));
const parent = await svc_fs.node(new NodePathSelector(`/${user.username}/Desktop`));
console.log('test -> create-example-file',
user, name, contents);
user,
name,
contents);
const buffer = Buffer.from(contents);
const file = {
size: buffer.length,
@@ -184,7 +178,7 @@ class ShareTestService extends use.Service {
file,
});
}
async ['__scenario:assert-no-access'] (
async ['__scenario:assert-no-access'](
{ actor, user },
{ path },
) {
@@ -195,26 +189,24 @@ class ShareTestService extends use.Service {
const stream = await ll_read.run({
fsNode: node,
actor,
})
} catch (e) {
});
} catch(e) {
expected_e = e;
}
if ( ! expected_e ) {
return { message: 'expected error, got none' };
}
}
async ['__scenario:grant'] (
async ['__scenario:grant'](
{ actor, user },
{ to, permission },
) {
const svc_permission = this.services.get('permission');
await svc_permission.grant_user_user_permission(
actor, to, permission, {}, {},
);
await svc_permission.grant_user_user_permission(actor, to, permission, {}, {});
}
async ['__scenario:assert-access'] (
async ['__scenario:assert-access'](
{ actor, user },
{ path, level }
{ path, level },
) {
const svc_fs = this.services.get('filesystem');
const svc_acl = this.services.get('acl');
@@ -224,23 +216,28 @@ class ShareTestService extends use.Service {
if ( level !== 'write' && level !== 'read' ) {
return {
message: 'unexpected value for "level" parameter'
message: 'unexpected value for "level" parameter',
};
}
if ( level === 'read' && has_write ) {
return {
message: 'expected read-only but actor can write'
message: 'expected read-only but actor can write',
};
}
if ( level === 'read' && !has_read ) {
return {
message: 'expected read access but no read access'
message: 'expected read access but no read access',
};
}
if ( level === 'write' && (!has_write || !has_read) ) {
return {
message: 'expected write access but no write access'
message: 'expected write access but no write access',
};
}
if ( level === 'manage' && (!has_write || !has_read) ) {
return {
message: 'expected write access but no write access',
};
}
}

View File

@@ -79,7 +79,7 @@ const install = async ({ context, services, app, useapi, modapi }) => {
def('Library', Library);
def('core.util.helpers', require('./helpers'));
def('core.util.permission', require('./services/auth/PermissionUtils.mjs').PermissionUtil);
def('core.util.permission', require('./services/auth/permissionUtils.mjs').PermissionUtil);
def('puter.middlewares.auth', require('./middleware/auth2'));
def('puter.middlewares.configurable_auth', require('./middleware/configurable_auth'));
def('puter.middlewares.anticsrf', require('./middleware/anticsrf'));

View File

@@ -16,7 +16,7 @@
* 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 { get_user, get_dir_size, id2path, id2uuid, is_empty, is_shared_with_anyone, suggest_app_for_fsentry, get_app } = require("../helpers");
const { get_user, id2path, id2uuid, is_empty, suggest_app_for_fsentry, get_app } = require("../helpers");
const putility = require('@heyputer/putility');
const config = require("../config");
@@ -26,8 +26,9 @@ const { Context } = require("../util/context");
const { NodeRawEntrySelector } = require("./node/selectors");
const { DB_READ } = require("../services/database/consts");
const { UserActorType, AppUnderUserActorType, Actor } = require("../services/auth/Actor");
const { PermissionUtil } = require("../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../services/auth/permissionUtils.mjs");
const { ECMAP } = require("./ECMAP");
const { MANAGE_PERM_PREFIX } = require("../services/auth/permissionConts.mjs");
/**
* Container for information collected about a node
@@ -72,25 +73,25 @@ module.exports = class FSNodeContext {
* @param {*} opt_identifier.id please pass mysql_id instead
* @param {*} opt_identifier.mysql_id a MySQL ID of the filesystem entry
*/
constructor ({
constructor({
services,
selector,
provider,
fs
fs,
}) {
const ecmap = Context.get(ECMAP.SYMBOL);
if ( ecmap ) {
// We might return an existing FSNodeContext
const maybe_node = ecmap?.
get_fsNodeContext_from_selector?.(selector);
const maybe_node = ecmap
?.get_fsNodeContext_from_selector?.(selector);
if ( maybe_node ) return maybe_node;
} else {
if ( process.env.LOG_ECMAP ) {
console.log('\x1B[31;1m !!! NO ECMAP !!! \x1B[0m');
}
}
// This will be used to avoid concurrent fetches. Whenever an entry is being fetched,
// a subsequent call to fetchEntry must await this promise. Usually this means the
// subsequent call will not perform any expensive operations.
@@ -142,16 +143,16 @@ module.exports = class FSNodeContext {
span.end();
});
return result;
}
};
}
}
set selector (new_selector) {
set selector(new_selector) {
// Only add the selector if we don't already have it
for ( const selector of this.selectors_ ) {
if ( selector instanceof new_selector.constructor ) return;
}
const ecmap = Context.get(ECMAP.SYMBOL);
if ( ecmap ) {
ecmap.store_fsNodeContext_to_selector(new_selector, this);
@@ -161,11 +162,11 @@ module.exports = class FSNodeContext {
this.selector_ = new_selector;
}
get selector () {
get selector() {
return this.get_optimal_selector();
}
get_selector_of_type (cls) {
get_selector_of_type(cls) {
// Reverse iterate over selectors
for ( let i = this.selectors_.length - 1; i >= 0; i-- ) {
const selector = this.selectors_[i];
@@ -181,7 +182,7 @@ module.exports = class FSNodeContext {
return null;
}
get_optimal_selector () {
get_optimal_selector() {
for ( const cls of FSNodeContext.SELECTOR_PRIORITY_ORDER ) {
const selector = this.get_selector_of_type(cls);
if ( selector ) return selector;
@@ -190,11 +191,11 @@ module.exports = class FSNodeContext {
return this.selector_;
}
get isRoot () {
get isRoot() {
return this.path === '/';
}
async isUserDirectory () {
async isUserDirectory() {
if ( this.isRoot ) return false;
if ( this.found === undefined ) {
await this.fetchEntry();
@@ -204,7 +205,7 @@ module.exports = class FSNodeContext {
return ! this.entry.parent_uid;
}
async isAppDataDirectory () {
async isAppDataDirectory() {
if ( this.isRoot ) return false;
if ( this.found === undefined ) {
await this.fetchEntry();
@@ -215,16 +216,16 @@ module.exports = class FSNodeContext {
if ( components.length < 2 ) return false;
return components[1] === 'AppData';
}
async isPublic () {
async isPublic() {
if ( this.isRoot ) return false;
const components = await this.getPathComponents();
if ( await this.isUserDirectory() ) return false;
if ( components[1] === 'Public' ) return true;
return false;
}
async getPathComponents () {
async getPathComponents() {
if ( this.isRoot ) return [];
// We can get path components for non-existing nodes if they
@@ -243,33 +244,31 @@ module.exports = class FSNodeContext {
if ( path.startsWith('/') ) path = path.slice(1);
return path.split('/');
}
async getUserPart () {
async getUserPart() {
if ( this.isRoot ) return;
const components = await this.getPathComponents();
return components[0];
}
async getPathSize () {
async getPathSize() {
if ( this.isRoot ) return;
const components = await this.getPathComponents();
return components.length;
}
async exists ({ fetch_options } = {}) {
async exists({ fetch_options } = {}) {
await this.fetchEntry(fetch_options);
if ( ! this.found ) {
this.log.debug(
'here\'s why it doesn\'t exist: ' +
this.log.debug('here\'s why it doesn\'t exist: ' +
this.selector.describe() + ' -> ' +
this.uid + ' ' +
JSON.stringify(this.entry, null, ' ')
);
JSON.stringify(this.entry, null, ' '));
}
return this.found;
}
async fetchPath () {
async fetchPath() {
if ( this.path ) return;
this.path = await this.services.get('information')
@@ -288,7 +287,7 @@ module.exports = class FSNodeContext {
* @param {*} fsEntryFetcher fetches the filesystem entry
* @void
*/
async fetchEntry (fetch_entry_options = {}) {
async fetchEntry(fetch_entry_options = {}) {
if ( this.fetching !== null ) {
await Context.get('services').get('traceService').spanify('fetching', async () => {
// ???: does this need to be double-checked? I'm not actually sure...
@@ -365,7 +364,7 @@ module.exports = class FSNodeContext {
*
* This just calls ResourceService under the hood.
*/
async awaitStableEntry () {
async awaitStableEntry() {
const resourceService = Context.get('services').get('resourceService');
await resourceService.waitForResource(this.selector);
}
@@ -378,24 +377,22 @@ module.exports = class FSNodeContext {
*
* @param fs:decouple-subdomains
*/
async fetchSubdomains (user, force) {
async fetchSubdomains(user, _force) {
if ( ! this.entry.is_dir ) return;
const db = this.services.get('database').get(DB_READ, 'filesystem');
this.entry.subdomains = []
let subdomains = await db.read(
`SELECT * FROM subdomains WHERE root_dir_id = ? AND user_id = ?`,
[this.entry.id, user.id]
);
if(subdomains.length > 0){
subdomains.forEach((sd)=>{
this.entry.subdomains = [];
let subdomains = await db.read(`SELECT * FROM subdomains WHERE root_dir_id = ? AND user_id = ?`,
[this.entry.id, user.id]);
if ( subdomains.length > 0 ){
subdomains.forEach((sd) => {
this.entry.subdomains.push({
subdomain: sd.subdomain,
address: config.protocol + '://' + sd.subdomain + "." + 'puter.site',
uuid: sd.uuid,
})
})
});
});
this.entry.has_website = true;
}
}
@@ -405,7 +402,7 @@ module.exports = class FSNodeContext {
* `owner` property of the fsentry.
* @param {bool} force fetch owner if it was already fetched
*/
async fetchOwner (force) {
async fetchOwner(_force) {
if ( this.isRoot ) return;
const owner = await get_user({ id: this.entry.user_id });
this.entry.owner = {
@@ -420,52 +417,79 @@ module.exports = class FSNodeContext {
* of the fsentry.
* @param {bool} force fetch shares if they were already fetched
*/
async fetchShares (force) {
if (this.entry.shares && ! force ) return;
async fetchShares(force) {
if ( this.entry.shares && ! force ) return;
const actor = Context.get('actor');
if ( ! actor ) {
this.entry.shares = { users: [], apps: [] };
return;
}
if ( ! (actor.type instanceof UserActorType) ) {
this.entry.shares = { users: [], apps: [] };
return;
}
const svc_permission = this.services.get('permission');
const permissions =
await svc_permission.query_issuer_permissions_by_prefix(
actor.type.user, `fs:${await this.get('uid')}:`);
const fsPermPrefix = `fs:${await this.get('uid')}`;
const [readWritePerms, managePerms] = await Promise.all([
svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${fsPermPrefix}:`),
svc_permission.query_issuer_permissions_by_prefix(actor.type.user, `${MANAGE_PERM_PREFIX}:${fsPermPrefix}`),
]);
this.entry.shares = { users: [], apps: [] };
for ( const user_perm of permissions.users ) {
for ( const readWriteUserPerms of readWritePerms.users ) {
const access =
PermissionUtil.split(user_perm.permission).slice(-1)[0];
PermissionUtil.split(readWriteUserPerms.permission).slice(-1)[0];
this.entry.shares.users.push({
user: {
uid: user_perm.user.uuid,
username: user_perm.user.username,
uid: readWriteUserPerms.user.uuid,
username: readWriteUserPerms.user.username,
},
access,
permission: user_perm.permission,
permission: readWriteUserPerms.permission,
});
}
for ( const manageUserPerms of managePerms.users ) {
const access = MANAGE_PERM_PREFIX;
this.entry.shares.users.push({
user: {
uid: manageUserPerms.user.uuid,
username: manageUserPerms.user.username,
},
access,
permission: manageUserPerms.permission,
});
}
for ( const app_perm of permissions.apps ) {
for ( const readWriteAppPerms of readWritePerms.apps ) {
const access =
PermissionUtil.split(app_perm.permission).slice(-1)[0];
PermissionUtil.split(readWriteAppPerms.permission).slice(-1)[0];
this.entry.shares.apps.push({
app: {
icon: app_perm.app.icon,
uid: app_perm.app.uid,
name: app_perm.app.name,
icon: readWriteAppPerms.app.icon,
uid: readWriteAppPerms.app.uid,
name: readWriteAppPerms.app.name,
},
access,
permission: app_perm.permission,
permission: readWriteAppPerms.permission,
});
}
for ( const manageAppPerms of readWritePerms.apps ) {
const access =
MANAGE_PERM_PREFIX;
this.entry.shares.apps.push({
app: {
icon: manageAppPerms.app.icon,
uid: manageAppPerms.app.uid,
name: manageAppPerms.app.name,
},
access,
permission: manageAppPerms.permission,
});
}
}
@@ -478,26 +502,24 @@ module.exports = class FSNodeContext {
*
* @todo fs:decouple-versions
*/
async fetchVersions (force) {
async fetchVersions(force) {
if ( this.entry.versions && ! force ) return;
const db = this.services.get('database').get(DB_READ, 'filesystem');
let versions = await db.read(
`SELECT * FROM fsentry_versions WHERE fsentry_id = ?`,
[this.entry.id]
);
let versions = await db.read(`SELECT * FROM fsentry_versions WHERE fsentry_id = ?`,
[this.entry.id]);
const versions_tidy = [];
for ( const version of versions ) {
let username = version.user_id ? (await get_user({id: version.user_id})).username : null;
let username = version.user_id ? (await get_user({ id: version.user_id })).username : null;
versions_tidy.push({
id: version.version_id,
message: version.message,
timestamp: version.ts_epoch,
user: {
username: username,
}
})
},
});
}
this.entry.versions = versions_tidy;
@@ -507,7 +529,7 @@ module.exports = class FSNodeContext {
* Fetches the size of a file or directory if it was not
* already fetched.
*/
async fetchSize () {
async fetchSize() {
const { fsEntryService } = Context.get('services').values;
// we already have the size for files
@@ -516,14 +538,12 @@ module.exports = class FSNodeContext {
return this.entry.size;
}
this.entry.size = await fsEntryService.get_recursive_size(
this.entry.uuid,
);
this.entry.size = await fsEntryService.get_recursive_size(this.entry.uuid);
return this.entry.size;
}
async fetchSuggestedApps (user, force) {
async fetchSuggestedApps(user, force) {
if ( this.entry.suggested_apps && ! force ) return;
await this.fetchEntry();
@@ -533,7 +553,7 @@ module.exports = class FSNodeContext {
await suggest_app_for_fsentry(this.entry, { user });
}
async fetchIsEmpty () {
async fetchIsEmpty() {
if ( ! this.uid && ! this.path ) return;
this.entry.is_empty = await is_empty({
uid: this.uid,
@@ -541,7 +561,7 @@ module.exports = class FSNodeContext {
});
}
async fetchAll(fsEntryFetcher, user, force) {
async fetchAll(_fsEntryFetcher, user, _force) {
await this.fetchEntry({ thumbnail: true });
await this.fetchSubdomains(user);
await this.fetchOwner();
@@ -552,7 +572,7 @@ module.exports = class FSNodeContext {
await this.fetchIsEmpty();
}
async get (key) {
async get(key) {
/*
This isn't supposed to stay like this!
@@ -566,19 +586,15 @@ module.exports = class FSNodeContext {
*/
if ( this.found === false ) {
throw new Error(
`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true)
);
throw new Error(`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true));
}
if ( key === 'entry' ) {
await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true)
);
throw new Error(`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true));
}
return this.entry;
}
@@ -586,10 +602,8 @@ module.exports = class FSNodeContext {
if ( key === 'path' ) {
if ( ! this.path ) await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get path of non-existent fsentry: ` +
this.selector.describe(true)
);
throw new Error(`Tried to get path of non-existent fsentry: ` +
this.selector.describe(true));
}
if ( ! this.path ) {
await this.fetchPath();
@@ -609,7 +623,7 @@ module.exports = class FSNodeContext {
await this.fetchEntry();
return this.mysql_id;
}
if ( key === 'owner' ) {
const user_id = await this.get('user_id');
const actor = new Actor({
@@ -625,10 +639,8 @@ module.exports = class FSNodeContext {
if ( key === k ) {
await this.fetchEntry();
if ( this.found === false ) {
throw new Error(
`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true)
);
throw new Error(`Tried to get ${key} of non-existent fsentry: ` +
this.selector.describe(true));
}
return this.entry[k];
}
@@ -674,7 +686,7 @@ module.exports = class FSNodeContext {
await this.fetchEntry();
return this.isRoot;
}
if ( key === 'writable' ) {
const actor = Context.get('actor');
if ( !actor || !actor.type.user ) return undefined;
@@ -685,7 +697,7 @@ module.exports = class FSNodeContext {
throw new Error(`unrecognize key for FSNodeContext.get: ${key}`);
}
async getParent () {
async getParent() {
if ( this.isRoot ) {
throw new Error('tried to get parent of root');
}
@@ -693,7 +705,7 @@ module.exports = class FSNodeContext {
if ( this.path ) {
const parent_fsNode = await this.fs.node({
path: _path.dirname(this.path),
})
});
return parent_fsNode;
}
@@ -714,21 +726,20 @@ module.exports = class FSNodeContext {
return this.fs.node(new NodeUIDSelector(parent_uid));
}
async getChild (name) {
async getChild(name) {
// If we have a path, we can get an FSNodeContext for the child
// without fetching anything.
if ( this.path ) {
const child_fsNode = await this.fs.node({
path: _path.join(this.path, name),
})
});
return child_fsNode;
}
return await this.fs.node(new NodeChildSelector(
this.selector, name));
return await this.fs.node(new NodeChildSelector(this.selector, name));
}
async getTarget () {
async getTarget() {
await this.fetchEntry();
const type = await this.get('type');
@@ -745,7 +756,7 @@ module.exports = class FSNodeContext {
return this;
}
async is_above (child_fsNode) {
async is_above(child_fsNode) {
if ( this.isRoot ) return true;
const path_this = await this.get('path');
@@ -754,7 +765,7 @@ module.exports = class FSNodeContext {
return path_child.startsWith(path_this + '/');
}
async is (fsNode) {
async is(fsNode) {
if ( this.mysql_id && fsNode.mysql_id ) {
return this.mysql_id === fsNode.mysql_id;
}
@@ -768,12 +779,10 @@ module.exports = class FSNodeContext {
return this.uid === fsNode.uid;
}
async getSafeEntry (fetch_options = {}) {
async getSafeEntry(fetch_options = {}) {
if ( this.found === false ) {
throw new Error(
`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true)
);
throw new Error(`Tried to get entry of non-existent fsentry: ` +
this.selector.describe(true));
}
await this.fetchEntry(fetch_options);
@@ -794,7 +803,9 @@ module.exports = class FSNodeContext {
let actor; try {
actor = Context.get('actor');
} catch (e) {}
} catch ( _e ) {
// fail silently
}
if ( ! actor?.type?.user || actor.type.user.id !== res.user_id ) {
if ( ! fsentry.owner ) await this.fetchOwner();
fsentry.owner = {
@@ -808,11 +819,9 @@ module.exports = class FSNodeContext {
const info = this.services.get('information');
if ( ! this.uid && ! this.entry.uuid ) {
this.log.noticeme(
'whats even happening!?!? ' +
this.selector.describe() + ' ' +
JSON.stringify(this.entry, null, ' ')
);
this.log.noticeme('whats even happening!?!? ' +
this.selector.describe() + ' ' +
JSON.stringify(this.entry, null, ' '));
}
// If fsentry was found by a path but the entry doesn't
@@ -821,7 +830,7 @@ module.exports = class FSNodeContext {
.with('fs.fsentry:uuid')
.obtain('fs.fsentry:path')
.exec(this.uid ?? this.entry.uuid);
if ( fsentry.path && fsentry.path.startsWith('/-void/') ) {
fsentry.broken = true;
}
@@ -857,13 +866,13 @@ module.exports = class FSNodeContext {
try {
fsentry.shortcut_to_path = (res.shortcut_to
? await id2path(res.shortcut_to) : undefined);
} catch (e) {
} catch ( _e ) {
fsentry.shortcut_invalid = true;
fsentry.shortcut_uid = res.shortcut_to;
}
// Add file_request_url
if(res.file_request_token && res.file_request_token !== ''){
if ( res.file_request_token && res.file_request_token !== '' ){
fsentry.file_request_url = config.origin +
'/upload?token=' + res.file_request_token;
}
@@ -872,7 +881,7 @@ module.exports = class FSNodeContext {
const app = await get_app({ id: fsentry.associated_app_id });
fsentry.associated_app = app;
}
// If this file is in an appdata directory, add `appdata_app`
const components = await this.getPathComponents();
if ( components[1] === 'AppData' ) {
@@ -889,7 +898,7 @@ module.exports = class FSNodeContext {
return fsentry;
}
static sanitize_pending_entry_info (res) {
static sanitize_pending_entry_info(res) {
const fsentry = {};
// This property will not be serialized, but it can be checked
@@ -924,4 +933,4 @@ module.exports = class FSNodeContext {
return fsentry;
}
}
};

View File

@@ -19,31 +19,25 @@
// TODO: database access can be a service
const { RESOURCE_STATUS_PENDING_CREATE } = require('../modules/puterfs/ResourceService.js');
const { TraceService } = require('../services/TraceService.js');
const PerformanceMonitor = require('../monitor/PerformanceMonitor.js');
const { NodePathSelector, NodeUIDSelector, NodeInternalIDSelector } = require('./node/selectors.js');
const FSNodeContext = require('./FSNodeContext.js');
const { AdvancedBase } = require('@heyputer/putility');
const { Context } = require('../util/context.js');
const { simple_retry } = require('../util/retryutil.js');
const APIError = require('../api/APIError.js');
const { LLMkdir } = require('./ll_operations/ll_mkdir.js');
const { LLCWrite, LLOWrite } = require('./ll_operations/ll_write.js');
const { LLCopy } = require('./ll_operations/ll_copy.js');
const { PermissionUtil, PermissionRewriter, PermissionImplicator, PermissionExploder } = require('../services/auth/PermissionUtils.mjs');
const { DB_WRITE } = require("../services/database/consts");
const { PermissionUtil, PermissionRewriter, PermissionImplicator, PermissionExploder } = require('../services/auth/permissionUtils.mjs');
const { DB_WRITE } = require('../services/database/consts');
const { UserActorType } = require('../services/auth/Actor');
const { get_user } = require('../helpers');
const BaseService = require('../services/BaseService');
const { PuterFSProvider } = require('../modules/puterfs/lib/PuterFSProvider.js');
const { MANAGE_PERM_PREFIX } = require('../services/auth/permissionConts.mjs');
class FilesystemService extends BaseService {
static MODULES = {
_path: require('path'),
uuidv4: require('uuid').v4,
config: require('../config.js'),
}
};
old_constructor (args) {
old_constructor(args) {
const { services } = args;
services.registerService('traceService', TraceService);
@@ -65,19 +59,19 @@ class FilesystemService extends BaseService {
});
}
async _init () {
async _init() {
this.old_constructor({ services: this.services });
const svc_permission = this.services.get('permission');
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('fs:') ) return false;
const [_, specifier] = PermissionUtil.split(permission);
if ( !permission.startsWith('fs:') && !permission.startsWith('manage:fs:') ) return false;
const [_, specifier] = permission.split('fs:');
if ( ! specifier.startsWith('/') ) return false;
return true;
},
rewriter: async permission => {
const [_, path, ...rest] = PermissionUtil.split(permission);
console.log('checking path: ', path);
const [manageOpt, pathPerm] = permission.split('fs:');
const [path, ...rest] = PermissionUtil.split(pathPerm);
const node = await this.node(new NodePathSelector(path));
if ( ! await node.exists() ) {
// TOOD: we need a general-purpose error that can have
@@ -89,21 +83,24 @@ class FilesystemService extends BaseService {
if ( uid === undefined || uid === 'undefined' ) {
throw new Error(`uid is undefined for path ${path}`);
}
return `fs:${uid}:${rest.join(':')}`;
return [manageOpt.replace(':', ''), 'fs', uid, ...rest].filter(Boolean).join(':');
},
}));
svc_permission.register_implicator(PermissionImplicator.create({
id: 'is-owner',
shortcut: true,
matcher: permission => {
return permission.startsWith('fs:');
// TODO DS: for now users will only have manage access on files, that might change, and then this has to change too
return permission.startsWith('fs:')
|| permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`)
|| permission.startsWith(`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`); // owner has implicit rule to give others manage access;
},
checker: async ({ actor, permission }) => {
if ( !(actor.type instanceof UserActorType) ) {
return undefined;
}
const [_, uid] = PermissionUtil.split(permission);
const [_, uid] = PermissionUtil.split(permission.replaceAll(`${MANAGE_PERM_PREFIX}:`, ''));
const node = await this.node(new NodeUIDSelector(uid));
if ( ! await node.exists() ) {
@@ -111,12 +108,10 @@ class FilesystemService extends BaseService {
}
const owner_id = await node.get('user_id');
// These conditions should never happen
if ( ! owner_id || ! actor.type.user.id ) {
throw new Error(
'something unexpected happened'
);
throw new Error('something unexpected happened');
}
if ( owner_id === actor.type.user.id ) {
@@ -134,32 +129,26 @@ class FilesystemService extends BaseService {
},
exploder: async ({ permission }) => {
const permissions = [permission];
const parts = PermissionUtil.split(permission);
const [fsPrefix, fileId, specifiedMode, ...rest] = PermissionUtil.split(permission);
const specified_mode = parts[2];
const rules = {
see: ['list', 'read', 'write'],
list: ['read', 'write'],
read: ['write'],
};
if ( rules.hasOwnProperty(specified_mode) ) {
permissions.push(...rules[specified_mode].map(
mode => PermissionUtil.join(
parts[0], parts[1],
mode,
...parts.slice(3),
)
));
if ( rules[specifiedMode] ) {
permissions.push(...rules[specifiedMode].map(mode => PermissionUtil.join(fsPrefix, fileId, mode, ...rest.slice(1))));
// push manage permission as well
permissions.push(PermissionUtil.join(MANAGE_PERM_PREFIX, fsPrefix, fileId));
}
return permissions;
},
}));
}
async mkshortcut ({ parent, name, user, target }) {
async mkshortcut({ parent, name, user, target }) {
// Access Control
{
@@ -192,7 +181,7 @@ class FilesystemService extends BaseService {
status: RESOURCE_STATUS_PENDING_CREATE,
});
console.log('registered entry')
console.log('registered entry');
const raw_fsentry = {
is_shortcut: 1,
@@ -210,7 +199,7 @@ class FilesystemService extends BaseService {
immutable: false,
};
this.log.debug('creating fsentry', { fsentry: raw_fsentry })
this.log.debug('creating fsentry', { fsentry: raw_fsentry });
const entryOp = await svc_fsEntry.insert(raw_fsentry);
@@ -218,7 +207,7 @@ class FilesystemService extends BaseService {
(async () => {
await entryOp.awaitDone();
this.log.debug('finished creating fsentry', { uid })
this.log.debug('finished creating fsentry', { uid });
resourceService.free(uid);
})();
@@ -233,7 +222,7 @@ class FilesystemService extends BaseService {
return node;
}
async mklink ({ parent, name, user, target }) {
async mklink({ parent, name, user, target }) {
// Access Control
{
@@ -274,13 +263,13 @@ class FilesystemService extends BaseService {
immutable: false,
};
this.log.debug('creating symlink', { fsentry: raw_fsentry })
this.log.debug('creating symlink', { fsentry: raw_fsentry });
const entryOp = await svc_fsEntry.insert(raw_fsentry);
(async () => {
await entryOp.awaitDone();
this.log.debug('finished creating symlink', { uid })
this.log.debug('finished creating symlink', { uid });
resourceService.free(uid);
})();
@@ -295,17 +284,15 @@ class FilesystemService extends BaseService {
return node;
}
async update_child_paths (old_path, new_path, user_id) {
async update_child_paths(old_path, new_path, user_id) {
const svc_performanceMonitor = this.services.get('performance-monitor');
const monitor = svc_performanceMonitor.createContext('update_child_paths');
if ( ! old_path.endsWith('/') ) old_path += '/';
if ( ! new_path.endsWith('/') ) new_path += '/';
// TODO: fs:decouple-tree-storage
await this.db.write(
`UPDATE fsentries SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path LIKE ? AND user_id = ?`,
[new_path, old_path.length + 1, old_path + '%', user_id]
);
await this.db.write('UPDATE fsentries SET path = CONCAT(?, SUBSTRING(path, ?)) WHERE path LIKE ? AND user_id = ?',
[new_path, old_path.length + 1, `${old_path}%`, user_id]);
const log = this.services.get('log-service').create('update_child_paths');
log.info(`updated ${old_path} -> ${new_path}`);
@@ -322,7 +309,7 @@ class FilesystemService extends BaseService {
* @param {*} location - path, uid, or id associated with a filesystem node
* @returns
*/
async node (selector) {
async node(selector) {
if ( typeof selector === 'string' ) {
if ( selector.startsWith('/') ) {
selector = new NodePathSelector(selector);
@@ -339,20 +326,19 @@ class FilesystemService extends BaseService {
} else if ( selector.uid ) {
selector = new NodeUIDSelector(selector.uid);
} else {
selector = new NodeInternalIDSelector(
'mysql', selector.mysql_id);
selector = new NodeInternalIDSelector('mysql', selector.mysql_id);
}
}
system_dir_check: {
if ( ! (selector instanceof NodePathSelector) ) break system_dir_check;
if ( ! selector.value.startsWith('/')) break system_dir_check;
if ( ! selector.value.startsWith('/') ) break system_dir_check;
// OPTIMIZATION: Check if the path matches a system directory pattern.
const systemDirRegex = /^\/([a-zA-Z0-9_]+)\/(Trash|AppData|Desktop|Documents|Pictures|Videos|Public)$/;
const match = selector.value.match(systemDirRegex);
if ( ! match ) break system_dir_check;
const username = match[1];
const dirName = match[2];
@@ -360,7 +346,7 @@ class FilesystemService extends BaseService {
const user = await get_user({ username });
if ( ! user ) break system_dir_check;
let uuidKey = ( selector.value === '/' + user.username )
let uuidKey = ( selector.value === `/${user.username}` )
? 'home_uuid'
: `${dirName.toLowerCase()}_uuid`; // e.g., 'desktop_uuid'
@@ -378,9 +364,9 @@ class FilesystemService extends BaseService {
provider,
services: this.services,
selector,
fs: this
fs: this,
});
return fsNode;
}
@@ -401,7 +387,7 @@ class FilesystemService extends BaseService {
* @param {*} param0.id please use mysql_id instead
* @param {*} param0.mysql_id
*/
async get_entry ({ path, uid, id, mysql_id, ...options }) {
async get_entry({ path, uid, id, mysql_id, ...options }) {
let fsNode = await this.node({ path, uid, id, mysql_id });
await fsNode.fetchEntry(options);
return fsNode.entry;
@@ -409,5 +395,5 @@ class FilesystemService extends BaseService {
}
module.exports = {
FilesystemService
FilesystemService,
};

View File

@@ -17,7 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { get_user } = require("../../helpers");
const { PermissionUtil } = require("../../services/auth/PermissionUtils.mjs");
const { MANAGE_PERM_PREFIX } = require("../../services/auth/permissionConts.mjs");
const { PermissionUtil } = require("../../services/auth/permissionUtils.mjs");
const { DB_WRITE } = require("../../services/database/consts");
const { NodeUIDSelector } = require("../node/selectors");
const { LLFilesystemOperation } = require("./definitions");
@@ -32,8 +33,8 @@ class LLReadShares extends LLFilesystemOperation {
found with "see" permission is found, children of that node
will not be traversed.
`;
async _run () {
async _run() {
const { subject, user, actor } = this.values;
const svc = this.context.get('services');
@@ -44,16 +45,14 @@ class LLReadShares extends LLFilesystemOperation {
const issuer_username = await subject.getUserPart();
const issuer_user = await get_user({ username: issuer_username });
const rows = await db.read(
'SELECT DISTINCT permission FROM `user_to_user_permissions` ' +
const rows = await db.read('SELECT DISTINCT permission FROM `user_to_user_permissions` ' +
'WHERE `holder_user_id` = ? AND `issuer_user_id` = ? ' +
'AND `permission` LIKE ?',
[user.id, issuer_user.id, 'fs:%']
);
'AND (`permission` LIKE ? OR `permission` LIKE ?)',
[user.id, issuer_user.id, 'fs:%', 'manage:fs:%']);
const fsentry_uuids = [];
for ( const row of rows ) {
const parts = PermissionUtil.split(row.permission);
const parts = PermissionUtil.split(row.permission.replace(`${MANAGE_PERM_PREFIX}:`, ''));
fsentry_uuids.push(parts[1]);
}

View File

@@ -20,7 +20,7 @@
const { get_app } = require("../../helpers");
const { UserActorType } = require("../../services/auth/Actor");
const { PermissionImplicator, PermissionUtil, PermissionRewriter } =
require("../../services/auth/PermissionUtils.mjs");
require("../../services/auth/permissionUtils.mjs");
const BaseService = require("../../services/BaseService");

View File

@@ -21,7 +21,7 @@
const { PassThrough } = require("stream");
const APIError = require("../../api/APIError");
const config = require("../../config");
const { PermissionUtil } = require("../../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../../services/auth/permissionUtils.mjs");
const BaseService = require("../../services/BaseService");
const { DB_WRITE } = require("../../services/database/consts");
const { TypeSpec } = require("../../services/drivers/meta/Construct");

View File

@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AppUnderUserActorType, UserActorType } = require("../../services/auth/Actor");
const { PermissionUtil } = require("../../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../../services/auth/permissionUtils.mjs");
const { Context } = require("../../util/context");
const { BaseES } = require("./BaseES");

View File

@@ -20,7 +20,7 @@ const APIError = require("../../api/APIError");
const eggspress = require("../../api/eggspress");
const { get_app } = require("../../helpers");
const { UserActorType, Actor, AppUnderUserActorType } = require("../../services/auth/Actor");
const { PermissionUtil } = require("../../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../../services/auth/permissionUtils.mjs");
const { Context } = require("../../util/context");
module.exports = eggspress('/auth/check-app', {

View File

@@ -0,0 +1,53 @@
/*
* 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 { UserActorType } = require('../../services/auth/Actor');
const { Context } = require('../../util/context');
const APIError = require('../../api/APIError');
module.exports = eggspress('/auth/check-permissions', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, _next) => {
const context = Context.get();
/** @type {import('../../services/auth/PermissionService').PermissionService} */
const permissionService = context.get('services').get('permission');
const permsToCheck = req.body.permissions;
const actor = context.get('actor');
// Apps cannot (currently) check permissions on behalf of users
if ( ! ( actor.type instanceof UserActorType ) ) {
throw APIError.create('forbidden');
}
const permEntryPromises = [...new Set(permsToCheck)].map(async (perm) => {
try {
return [perm, permissionService.check(actor, perm)];
} catch {
return [perm, false];
}
});
const permEntries = Promise.all(permEntryPromises);
res.json({ permissions: Object.fromEntries(await permEntries) });
});

View File

@@ -26,7 +26,7 @@ const { TYPE_DIRECTORY } = require("../../filesystem/FSNodeContext");
const { LLRead } = require("../../filesystem/ll_operations/ll_read");
const { Actor, UserActorType, SiteActorType } = require("../../services/auth/Actor");
const APIError = require("../../api/APIError");
const { PermissionUtil } = require("../../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../../services/auth/permissionUtils.mjs");
const { default: dedent } = require("dedent");
const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf';

View File

@@ -20,7 +20,7 @@
// METADATA // {"ai-commented":{"service":"claude"}}
const { Context } = require("../util/context");
const { whatis } = require("../util/langutil");
const { PermissionUtil } = require("./auth/PermissionUtils.mjs");
const { PermissionUtil } = require("./auth/permissionUtils.mjs");
const BaseService = require("./BaseService");
/**

View File

@@ -23,7 +23,6 @@ const { Endpoint } = require("../util/expressutil");
const { whatis } = require("../util/langutil");
const BaseService = require("./BaseService");
/**
* @class PermissionAPIService
* @extends BaseService
@@ -37,7 +36,6 @@ class PermissionAPIService extends BaseService {
express: require('express'),
};
/**
* Installs routes for authentication and permission management into the Express app
* @param {Object} _ Unused parameter
@@ -45,24 +43,23 @@ class PermissionAPIService extends BaseService {
* @param {Express} options.app Express application instance to install routes on
* @returns {Promise<void>}
*/
async ['__on_install.routes'] (_, { app }) {
app.use(require('../routers/auth/get-user-app-token'))
app.use(require('../routers/auth/grant-user-app'))
app.use(require('../routers/auth/revoke-user-app'))
app.use(require('../routers/auth/grant-dev-app'))
app.use(require('../routers/auth/revoke-dev-app'))
async ['__on_install.routes'](_, { app }) {
app.use(require('../routers/auth/get-user-app-token'));
app.use(require('../routers/auth/grant-user-app'));
app.use(require('../routers/auth/revoke-user-app'));
app.use(require('../routers/auth/grant-dev-app'));
app.use(require('../routers/auth/revoke-dev-app'));
app.use(require('../routers/auth/grant-user-user'));
app.use(require('../routers/auth/revoke-user-user'));
app.use(require('../routers/auth/grant-user-group'));
app.use(require('../routers/auth/revoke-user-group'));
app.use(require('../routers/auth/list-permissions'))
app.use(require('../routers/auth/list-permissions'));
app.use(require('../routers/auth/check-permissions.js'));
Endpoint(
require('../routers/auth/check-app-acl.endpoint.js'),
).but({
Endpoint(require('../routers/auth/check-app-acl.endpoint.js')).but({
route: '/auth/check-app-acl',
}).attach(app);
// track: scoping iife
/**
* Creates a scoped router for group-related endpoints using an IIFE pattern
@@ -72,21 +69,21 @@ class PermissionAPIService extends BaseService {
const r_group = (() => {
const require = this.require;
const express = require('express');
return express.Router()
return express.Router();
})();
this.install_group_endpoints_({ router: r_group });
app.use('/group', r_group);
}
install_group_endpoints_ ({ router }) {
install_group_endpoints_({ router }) {
Endpoint({
route: '/create',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const owner_user_id = req.user.id;
const extra = req.body.extra ?? {};
const metadata = req.body.metadata ?? {};
if ( whatis(extra) !== 'object' ) {
@@ -94,14 +91,14 @@ class PermissionAPIService extends BaseService {
key: 'extra',
expected: 'object',
got: whatis(extra),
})
});
}
if ( whatis(metadata) !== 'object' ) {
throw APIError.create('field_invalid', null, {
key: 'metadata',
expected: 'object',
got: whatis(metadata),
})
});
}
const svc_group = this.services.get('group');
@@ -112,33 +109,32 @@ class PermissionAPIService extends BaseService {
// Metadata can be specified in request
metadata: metadata ?? {},
});
res.json({ uid });
}
},
}).attach(router);
Endpoint({
route: '/add-users',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const svc_group = this.services.get('group')
const svc_group = this.services.get('group');
// TODO: validate string and uuid for request
const group = await svc_group.get(
{ uid: req.body.uid });
const group = await svc_group.get({ uid: req.body.uid });
if ( ! group ) {
throw APIError.create('entity_not_found', null, {
identifier: req.body.uid,
})
});
}
if ( group.owner_user_id !== req.user.id ) {
throw APIError.create('forbidden');
}
if ( whatis(req.body.users) !== 'array' ) {
throw APIError.create('field_invalid', null, {
key: 'users',
@@ -146,8 +142,8 @@ class PermissionAPIService extends BaseService {
got: whatis(req.body.users),
});
}
for ( let i=0 ; i < req.body.users.length ; i++ ) {
for ( let i = 0 ; i < req.body.users.length ; i++ ) {
const value = req.body.users[i];
if ( whatis(value) === 'string' ) continue;
throw APIError.create('field_invalid', null, {
@@ -156,14 +152,14 @@ class PermissionAPIService extends BaseService {
got: whatis(value),
});
}
await svc_group.add_users({
uid: req.body.uid,
users: req.body.users,
});
res.json({});
}
},
}).attach(router);
// TODO: DRY: add-users is very similar
@@ -172,23 +168,22 @@ class PermissionAPIService extends BaseService {
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const svc_group = this.services.get('group')
const svc_group = this.services.get('group');
// TODO: validate string and uuid for request
const group = await svc_group.get(
{ uid: req.body.uid });
const group = await svc_group.get({ uid: req.body.uid });
if ( ! group ) {
throw APIError.create('entity_not_found', null, {
identifier: req.body.uid,
})
});
}
if ( group.owner_user_id !== req.user.id ) {
throw APIError.create('forbidden');
}
if ( whatis(req.body.users) !== 'array' ) {
throw APIError.create('field_invalid', null, {
key: 'users',
@@ -196,8 +191,8 @@ class PermissionAPIService extends BaseService {
got: whatis(req.body.users),
});
}
for ( let i=0 ; i < req.body.users.length ; i++ ) {
for ( let i = 0 ; i < req.body.users.length ; i++ ) {
const value = req.body.users[i];
if ( whatis(value) === 'string' ) continue;
throw APIError.create('field_invalid', null, {
@@ -206,14 +201,14 @@ class PermissionAPIService extends BaseService {
got: whatis(value),
});
}
await svc_group.remove_users({
uid: req.body.uid,
users: req.body.users,
});
res.json({});
}
},
}).attach(router);
Endpoint({
@@ -222,26 +217,21 @@ class PermissionAPIService extends BaseService {
mw: [configurable_auth()],
handler: async (req, res) => {
const svc_group = this.services.get('group');
// TODO: validate string and uuid for request
const owned_groups = await svc_group.list_groups_with_owner(
{ owner_user_id: req.user.id });
const owned_groups = await svc_group.list_groups_with_owner({ owner_user_id: req.user.id });
const in_groups = await svc_group.list_groups_with_member(
{ user_id: req.user.id });
const in_groups = await svc_group.list_groups_with_member({ user_id: req.user.id });
const public_groups = await svc_group.list_public_groups();
res.json({
owned_groups: await Promise.all(owned_groups.map(
g => g.get_client_value({ members: true }))),
in_groups: await Promise.all(in_groups.map(
g => g.get_client_value({ members: true }))),
public_groups: await Promise.all(public_groups.map(
g => g.get_client_value())),
owned_groups: await Promise.all(owned_groups.map(g => g.get_client_value({ members: true }))),
in_groups: await Promise.all(in_groups.map(g => g.get_client_value({ members: true }))),
public_groups: await Promise.all(public_groups.map(g => g.get_client_value())),
});
}
},
}).attach(router);
Endpoint({
@@ -253,7 +243,7 @@ class PermissionAPIService extends BaseService {
user: this.global_config.default_user_group,
temp: this.global_config.default_temp_group,
});
}
},
}).attach(router);
}
}

View File

@@ -19,7 +19,7 @@
*/
const { NodeInternalIDSelector, NodeUIDSelector } = require("../filesystem/node/selectors");
const { SiteActorType } = require("./auth/Actor");
const { PermissionUtil, PermissionRewriter, PermissionImplicator } = require("./auth/PermissionUtils.mjs");
const { PermissionUtil, PermissionRewriter, PermissionImplicator } = require("./auth/permissionUtils.mjs");
const BaseService = require("./BaseService");
const { DB_WRITE } = require("./database/consts");

View File

@@ -1,4 +1,4 @@
const { PermissionImplicator, PermissionUtil } = require("./auth/PermissionUtils.mjs");
const { PermissionImplicator, PermissionUtil } = require("./auth/permissionUtils.mjs");
const BaseService = require("./BaseService")
const APIError = require("../api/APIError");

View File

@@ -26,7 +26,8 @@ const { Context } = require('../../util/context');
const { Endpoint } = require('../../util/expressutil');
const BaseService = require('../BaseService');
const { AppUnderUserActorType, UserActorType, Actor, SystemActorType, AccessTokenActorType } = require('./Actor');
const { PermissionUtil } = require('./PermissionUtils.mjs');
const { MANAGE_PERM_PREFIX } = require('./permissionConts.mjs');
const { PermissionUtil } = require('./permissionUtils.mjs');
/**
* ACLService class handles Access Control List functionality for the Puter filesystem.
@@ -72,7 +73,7 @@ class ACLService extends BaseService {
*
* @param {Actor} actor - The actor requesting permission
* @param {FSNode} resource - The filesystem resource to check permissions for
* @param {('see'| 'list'| 'read'| 'write')} mode - The permission mode to check ('see', 'list', 'read', 'write')
* @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 () => {
@@ -227,11 +228,13 @@ class ACLService extends BaseService {
});
}
let uid, _;
let uid;
if ( typeof resource === 'string' && mode === undefined ) {
const perm_parts = PermissionUtil.split(resource);
([_, uid, mode] = perm_parts);
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');
@@ -249,7 +252,7 @@ class ACLService extends BaseService {
const perms_on_this = stat[await resource.get('path')] ?? [];
const mode_parts = perms_on_this.map(perm => PermissionUtil.split(perm)[2]);
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) ) {
@@ -259,7 +262,7 @@ class ACLService extends BaseService {
// If higher mode already present, do nothing
if ( options.only_if_higher ) {
const higher_modes = this._higher_modes(mode);
if ( mode_parts.some(m => higher_modes.includes(m)) ) {
if ( mode_parts.some(m => m === MANAGE_PERM_PREFIX || higher_modes.includes(m)) ) {
return false;
}
}
@@ -267,12 +270,12 @@ class ACLService extends BaseService {
uid = uid ?? await resource.get('uid');
// If mode not present, add it
await svc_perm.grant_user_user_permission(issuer, holder.type.user.username, PermissionUtil.join('fs', uid, mode));
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 perm_parts = PermissionUtil.split(perm);
if ( perm_parts[2] === mode ) continue;
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);
}
@@ -323,7 +326,7 @@ class ACLService extends BaseService {
*
* @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'} mode - The permission mode to check ('see', 'list', 'read', 'write')
* @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
@@ -483,11 +486,10 @@ class ACLService extends BaseService {
*/
const svc_permission = await context.get('services').get('permission');
const modes = [mode];
let perm_fsNode = fsNode;
while ( !await perm_fsNode.get('is-root') ) {
const uid = await perm_fsNode.get('uid');
const permissionsToCheck = modes.map(mode => PermissionUtil.join('fs', uid, mode));
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 ) {

View File

@@ -25,8 +25,8 @@ const { reading_has_terminal } = require('../../unstructured/permission-scan-lib
const BaseService = require('../BaseService');
const { DB_WRITE } = require('../database/consts');
const { UserActorType, Actor, AppUnderUserActorType } = require('./Actor');
const { PermissionUtil, PermissionExploder, PermissionImplicator, PermissionRewriter } = require('./PermissionUtils.mjs');
const PERM_KEY_PREFIX = 'perm';
const { PERM_KEY_PREFIX, MANAGE_PERM_PREFIX } = require('./permissionConts.mjs');
const { PermissionUtil, PermissionExploder, PermissionImplicator, PermissionRewriter } = require('./permissionUtils.mjs');
/**
* @class PermissionService
@@ -125,16 +125,11 @@ class PermissionService extends BaseService {
* Checks if the actor has any of the specified permissions.
*
* @param {Actor} actor - The actor to check permissions for.
* @param {Array|string} permission_options - The permissions to check against.
* @param {string[]|string} permission_options - The permissions to check against.
* Can be a single permission string or an array of permission strings.
* @returns {Promise<boolean>} - True if the actor has at least one of the permissions, false otherwise.
*
* @note This method currently delegates to `scan()`, but a TODO suggests
* an optimized implementation is planned.
*/
async check(actor, permission_options) {
// TODO: optimized implementation for check instead of
// delegating to the scan() method
const svc_trace = this.services.get('traceService');
return await svc_trace.spanify('permission:check', async () => {
const reading = await this.scan(actor, permission_options);
@@ -142,6 +137,22 @@ class PermissionService extends BaseService {
return options.length > 0;
});
}
/**
* Checks if the actor has grant access to any of the specified permissions.
*
* @param {Actor} actor - The actor to check if they can manage a permission.
* @param {string} permission - The permission to check against.
* @returns {Promise<boolean>} - True if the actor has at least one of the permissions, false otherwise.
*/
async canManagePermission(actor, permission) {
const svc_trace = this.services.get('traceService');
return await svc_trace.spanify('permission:check', async () => {
const managePermission = PermissionUtil.join(MANAGE_PERM_PREFIX, ...PermissionUtil.split(permission));
const reading = await this.scan(actor, managePermission);
const options = PermissionUtil.reading_to_options(reading);
return options.length > 0;
});
}
/**
* Scans the permissions for an actor against specified permission options.
@@ -366,11 +377,10 @@ class PermissionService extends BaseService {
}
/**
* Grants a user permission to interact with another user.
* Grants a user permission to an app the user is working with if the user has permission.
*
* @param {Actor} actor - The actor granting the permission (must be a user).
* @param {string} app_uid - The unique identifier or name of the app.
* @param {string} username - The username of the user receiving the permission.
* @param {string} permission - The permission string to grant.
* @param {Object} [extra={}] - Additional metadata or conditions for the permission.
* @param {Object} [meta] - Metadata for logging or auditing purposes.
@@ -420,18 +430,17 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
/**
* Grants an app a permission for any user, as long as the user granting the
* permission also has the permission.
* permission can manage permission.
*
* @param {Actor} actor - The actor granting the permission (must be a user).
* @param {string} app_uid - The unique identifier or name of the app.
* @param {string} username - The username of the user receiving the permission.
* @param {string} permission - The permission string to grant.
* @param {Object} [extra={}] - Additional metadata or conditions for the permission.
* @param {Object} [meta] - Metadata for logging or auditing purposes.
@@ -452,6 +461,13 @@ class PermissionService extends BaseService {
const app_id = app.id;
const canManagePerms = await this.canManagePermission(actor, permission);
if ( !canManagePerms ){
throw APIError.create('permission_denied', null, {
permission,
});
}
// UPSERT permission
await this.db.write('INSERT INTO `dev_to_app_permissions` (`user_id`, `app_id`, `permission`, `extra`) ' +
`VALUES (?, ?, ?, ?) ${
@@ -481,7 +497,7 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
@@ -525,7 +541,7 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
@@ -561,7 +577,7 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_dev_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
@@ -619,7 +635,7 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
@@ -664,20 +680,11 @@ class PermissionService extends BaseService {
const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', ');
const sql_vals = Object.keys(audit_values).map(() => '?').join(', ');
await this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
this.db.write(`INSERT INTO \`audit_user_to_app_permissions\` (${sql_cols}) ` +
`VALUES (${sql_vals})`,
Object.values(audit_values));
}
/**
* @typedef {Object} GrantUserUserPermissionParams
* @property {Actor} actor - The actor granting the permission (must be a user).
* @property {string} username - The username of the user receiving the permission.
* @property {string} permission - The permission string to be granted.
* @property {Object} [extra={}] - Additional metadata or conditions for the permission.
* @property {Object} [meta] - Metadata for auditing purposes, including a reason for the action.
*/
/**
* Grants a permission from one user to another.
*
@@ -685,75 +692,76 @@ class PermissionService extends BaseService {
* ensuring that the permission is correctly formatted, the users exist,
* and that self-granting is not allowed.
*
* @param {GrantUserUserPermissionParams} params - Parameters for granting permission.
* @param {Actor} actor
* @param {string} username
* @param {string} permission
* @param {object} extra
* @param {object} meta
* @throws {Error} Throws if the user is not found or if attempting to grant permissions to oneself.
* @returns {Promise<void>}
*/
async grant_user_user_permission(actor, username, permission, extra = {}, meta) {
const flatRes = this.#flat_grant_user_user_permission(actor, username, permission, extra, meta);
permission = await this._rewrite_permission(permission);
const user = await get_user({ username });
if ( ! user ) {
throw APIError.create('user_does_not_exist', null, {
username,
});
}
// Don't allow granting permissions to yourself
if ( user.id === actor.type.user.id ) {
throw new Error('cannot grant permissions to yourself');
}
const canManagePerms = await this.canManagePermission(actor, permission);
if ( !canManagePerms ){
throw APIError.create('permission_denied', null, {
permission,
});
}
const flatRes = this.#flat_grant_user_user_permission(actor, user, permission, extra);
// shoot this async
this.#linked_grant_user_user_permission(actor, username, permission, extra, meta);
this.#linked_grant_user_user_permission(actor, user, permission, extra, meta);
return flatRes;
}
/**
* @param {GrantUserUserPermissionParams} params - Parameters for granting permission.
* @param {Actor} actor
* @param {User} user
* @param {string} permission
* @param {object} extra
* @throws {Error} Throws if the user is not found or if attempting to grant permissions to oneself.
* @returns {Promise<void>}
*/
async #flat_grant_user_user_permission(actor, username, permission, extra = {}, _meta) {
permission = await this._rewrite_permission(permission);
const user = await get_user({ username });
if ( ! user ) {
throw APIError.create('user_does_not_exist', null, {
username,
});
}
// Don't allow granting permissions to yourself
if ( user.id === actor.type.user.id ) {
throw new Error('cannot grant permissions to yourself');
}
// TODO DS: for now I'm just gonna check that the actor has the perm they wanna give
const canManagePerms = await this.check(actor, permission);
async #flat_grant_user_user_permission(actor, user, permission, extra = {}) {
// UPSERT permission
if ( canManagePerms )
{
await this.services.get('su').sudo(() =>
this.kvService.set({
key: PermissionUtil.join(PERM_KEY_PREFIX, user.id, permission),
value: {
...extra,
issuer_user_id: actor.type.user.id,
permission,
deleted: false,
},
}));
}
await this.services
.get('su')
.sudo(() => this.kvService.set({
key: PermissionUtil.join(PERM_KEY_PREFIX, user.id, permission),
value: {
...extra,
issuer_user_id: actor.type.user.id,
permission,
deleted: false,
},
}));
}
/**
* @param {GrantUserUserPermissionParams} params - Parameters for granting permission.
* @param {Actor} actor
* @param {User} user
* @param {string} permission
* @param {object} extra
* @param {object} meta
* @throws {Error} Throws if the user is not found or if attempting to grant permissions to oneself.
* @returns {Promise<void>}
*/
async #linked_grant_user_user_permission(actor, username, permission, extra = {}, meta) {
permission = await this._rewrite_permission(permission);
const user = await get_user({ username });
if ( ! user ) {
throw APIError.create('user_does_not_exist', null, {
username,
});
}
// Don't allow granting permissions to yourself
if ( user.id === actor.type.user.id ) {
throw new Error('cannot grant permissions to yourself');
}
async #linked_grant_user_user_permission(actor, user, permission, extra = {}, meta) {
// UPSERT permission
await this.db.write('INSERT INTO `user_to_user_permissions` (`holder_user_id`, `issuer_user_id`, `permission`, `extra`) ' +
`VALUES (?, ?, ?, ?) ${
@@ -770,7 +778,7 @@ class PermissionService extends BaseService {
]);
// INSERT audit table
await this.db.write('INSERT INTO `audit_user_to_user_permissions` (' +
this.db.write('INSERT INTO `audit_user_to_user_permissions` (' +
'`holder_user_id`, `holder_user_id_keep`, `issuer_user_id`, `issuer_user_id_keep`, ' +
'`permission`, `action`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -809,6 +817,13 @@ class PermissionService extends BaseService {
});
}
const canManagePerms = await this.canManagePermission(actor, permission);
if ( !canManagePerms ){
throw APIError.create('permission_denied', null, {
permission,
});
}
await this.db.write('INSERT INTO `user_to_group_permissions` (`user_id`, `group_id`, `permission`, `extra`) ' +
`VALUES (?, ?, ?, ?) ${
this.db.case({
@@ -824,7 +839,7 @@ class PermissionService extends BaseService {
]);
// INSERT audit table
await this.db.write('INSERT INTO `audit_user_to_group_permissions` (' +
this.db.write('INSERT INTO `audit_user_to_group_permissions` (' +
'`user_id`, `user_id_keep`, `group_id`, `group_id_keep`, ' +
'`permission`, `action`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -870,24 +885,28 @@ class PermissionService extends BaseService {
permission = await this._rewrite_permission(permission);
const user = await get_user({ username });
if ( ! user ) {
if ( ! user ) {
if ( !user ) {
if ( !user ) {
throw APIError.create('user_does_not_exist', null, {
username,
});
}
}
// TODO DS: for now I'm just gonna check that the actor has the perm they wanna take away
const canManagePerms = await this.check(actor, permission);
const canManagePerms = await this.canManagePermission(actor, permission);
if ( canManagePerms ) {
// DELETE permission
await this.services.get('su').sudo(() =>
this.kvService.set(PermissionUtil.join(PERM_KEY_PREFIX, user.id, permission), {
deleted: true,
}));
if ( !canManagePerms ){
throw APIError.create('permission_denied', null, {
permission,
});
}
// DELETE permission
await this.services.get('su').sudo(() =>
this.kvService.set(PermissionUtil.join(PERM_KEY_PREFIX, user.id, permission), {
deleted: true,
}));
}
/**
* @param {RevokeUserUserPermissionParams} params - Parameters for revoking permission
@@ -915,7 +934,7 @@ class PermissionService extends BaseService {
]);
// INSERT audit table
await this.db.write('INSERT INTO `audit_user_to_user_permissions` (' +
this.db.write('INSERT INTO `audit_user_to_user_permissions` (' +
'`holder_user_id`, `holder_user_id_keep`, `issuer_user_id`, `issuer_user_id_keep`, ' +
'`permission`, `action`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -962,7 +981,7 @@ class PermissionService extends BaseService {
]);
// INSERT audit table
await this.db.write('INSERT INTO `audit_user_to_group_permissions` (' +
this.db.write('INSERT INTO `audit_user_to_group_permissions` (' +
'`user_id`, `user_id_keep`, `group_id`, `group_id_keep`, ' +
'`permission`, `action`, `reason`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',

View File

@@ -1,5 +1,5 @@
const BaseService = require('../BaseService');
const { PermissionImplicator } = require('./PermissionUtils.mjs');
const { PermissionImplicator } = require('./permissionUtils.mjs');
class PermissionShortcutService extends BaseService {
_init() {

View File

@@ -0,0 +1,2 @@
export const MANAGE_PERM_PREFIX = 'manage';
export const PERM_KEY_PREFIX = 'perm';

View File

@@ -1,16 +1,18 @@
import { MANAGE_PERM_PREFIX } from "./permissionConts.mjs";
/**
* The PermissionUtil class provides utility methods for handling
* permission strings and operations, including splitting, joining,
* escaping, and unescaping permission components. It also includes
* functionality to convert permission reading structures into options.
*/
export class PermissionUtil {
export const PermissionUtil = {
/**
* Unescapes a permission component string, converting escape sequences to their literal characters.
* @param {string} component - The escaped permission component string.
* @returns {string} The unescaped permission component.
*/
static unescape_permission_component(component) {
unescape_permission_component(component) {
let unescaped_str = '';
// Constant for unescaped permission component string
const STATE_NORMAL = {};
@@ -33,14 +35,14 @@ export class PermissionUtil {
}
}
return unescaped_str;
}
},
/**
* Escapes special characters in a permission component string for safe joining.
* @param {string} component - The permission component string to escape.
* @returns {string} The escaped permission component.
*/
static escape_permission_component(component) {
escape_permission_component(component) {
let escaped_str = '';
for ( let i = 0 ; i < component.length ; i++ ) {
const c = component[i];
@@ -51,31 +53,31 @@ export class PermissionUtil {
escaped_str += c;
}
return escaped_str;
}
},
/**
* Splits a permission string into its component parts, unescaping each component.
* @param {string} permission - The permission string to split.
* @returns {string[]} Array of unescaped permission components.
*/
static split(permission) {
split(permission) {
return permission
.split(':')
.map(PermissionUtil.unescape_permission_component)
;
}
},
/**
* Joins permission components into a single permission string, escaping as needed.
* @param {...string} components - The permission components to join.
* @returns {string} The escaped, joined permission string.
*/
static join(...components) {
join(...components) {
return components
.map(PermissionUtil.escape_permission_component)
.join(':')
;
}
},
/**
* Converts a permission reading structure into an array of option objects.
@@ -87,7 +89,7 @@ export class PermissionUtil {
* @param {Array<Object>} [path=[]] - Current path in the reading tree (used internally for recursion).
* @returns {Array<Object>} Array of option objects with path and data.
*/
static reading_to_options(
reading_to_options(
// actual arguments
reading, parameters = {},
// recursion state
@@ -121,8 +123,12 @@ export class PermissionUtil {
}
}
return options;
}
}
},
/** @type {(permission:string)=>boolean} */
isManage(permission ){
return permission.startsWith(MANAGE_PERM_PREFIX + ':');
},
};
/**
* Permission rewriters are used to map one set of permission strings to another.

View File

@@ -22,7 +22,7 @@ const APIError = require("../../api/APIError");
const { DriverError } = require("./DriverError");
const { TypedValue } = require("./meta/Runtime");
const BaseService = require("../BaseService");
const { PermissionUtil } = require("../auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../auth/permissionUtils.mjs");
const { Invoker } = require("../../../../putility/src/libs/invoker");
const { get_user } = require("../../helpers");
const { whatis } = require('../../util/langutil');

View File

@@ -18,7 +18,7 @@
*/
// METADATA // {"ai-commented":{"service":"openai-completion","model":"gpt-4o"}}
const { PermissionUtil } = require("../auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../auth/permissionUtils.mjs");
const BaseService = require("../BaseService");
// DO WE HAVE enough information to get the policy for the newer drivers?

View File

@@ -28,7 +28,7 @@ const { quot } = require('@heyputer/putility').libs.string;
This code is optimized for editors supporting folding.
Fold at Level 2 to conveniently browse sequence steps.
Fold at Level 3 after opening an inner-sequence.
If you're using VSCode {
typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
@@ -36,10 +36,9 @@ const { quot } = require('@heyputer/putility').libs.string;
}
*/
module.exports = new Sequence([
require('./share/validate.js'),
function initialize_result_object (a) {
function initialize_result_object(a) {
a.set('result', {
$: 'api:share',
$version: 'v0.0.0',
@@ -48,16 +47,16 @@ module.exports = new Sequence([
Array(a.get('req_recipients').length).fill(null),
shares:
Array(a.get('req_shares').length).fill(null),
serialize () {
serialize() {
const result = this;
for ( let i=0 ; i < result.recipients.length ; i++ ) {
for ( let i = 0 ; i < result.recipients.length ; i++ ) {
if ( ! result.recipients[i] ) continue;
if ( result.recipients[i] instanceof APIError ) {
result.status = 'mixed';
result.recipients[i] = result.recipients[i].serialize();
}
}
for ( let i=0 ; i < result.shares.length ; i++ ) {
for ( let i = 0 ; i < result.shares.length ; i++ ) {
if ( ! result.shares[i] ) continue;
if ( result.shares[i] instanceof APIError ) {
result.status = 'mixed';
@@ -66,38 +65,38 @@ module.exports = new Sequence([
}
delete result.serialize;
return result;
}
},
});
},
function initialize_worklists (a) {
function initialize_worklists(a) {
const recipients_work = new WorkList();
const shares_work = new WorkList();
const { req_recipients, req_shares } = a.values();
// track: common operations on multiple items
for ( let i=0 ; i < req_recipients.length ; i++ ) {
for ( let i = 0 ; i < req_recipients.length ; i++ ) {
const value = req_recipients[i];
recipients_work.push({ i, value });
}
for ( let i=0 ; i < req_shares.length ; i++ ) {
for ( let i = 0 ; i < req_shares.length ; i++ ) {
const value = req_shares[i];
shares_work.push({ i, value });
}
recipients_work.lockin();
shares_work.lockin();
a.values({ recipients_work, shares_work });
},
require('./share/process_recipients.js'),
require('./share/process_shares.js'),
function abort_on_error_if_mode_is_strict (a) {
function abort_on_error_if_mode_is_strict(a) {
const strict_mode = a.get('strict_mode');
if ( ! strict_mode ) return;
const result = a.get('result');
if (
result.recipients.some(v => v !== null) ||
@@ -110,70 +109,64 @@ module.exports = new Sequence([
a.stop();
}
},
function early_return_on_dry_run (a) {
function early_return_on_dry_run(a) {
if ( ! a.get('req').body.dry_run ) return;
const { res, result, recipients_work } = a.values();
for ( const item of recipients_work.list() ) {
result.recipients[item.i] =
{ $: 'api:status-report', status: 'success' };
}
result.serialize();
result.status = 'success';
result.dry_run = true;
res.send(result);
a.stop();
},
async function grant_permissions_to_existing_users (a) {
async function grant_permissions_to_existing_users(a) {
const {
req, result, recipients_work, shares_work
req, result, recipients_work, shares_work,
} = a.values();
const svc_permission = a.iget('services').get('permission');
const svc_acl = a.iget('services').get('acl');
const svc_notification = a.iget('services').get('notification');
const svc_email = a.iget('services').get('email');
const actor = a.get('actor');
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'username' ) continue;
const username = recipient_item.user.username;
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
for ( const perm of permissions ) {
if ( perm.startsWith('fs:') ) {
await svc_acl.set_user_user(
actor,
username,
perm,
undefined,
{ only_if_higher: true },
);
if ( perm.startsWith('fs:') || perm.startsWith('manage:fs:') ) {
await svc_acl.set_user_user(actor,
username,
perm,
undefined,
{ only_if_higher: true });
} else {
await svc_permission.grant_user_user_permission(
actor,
username,
perm,
);
await svc_permission.grant_user_user_permission(actor,
username,
perm);
}
}
}
const files = []; {
for ( const item of shares_work.list() ) {
if ( item.thing.$ !== 'fs-share' ) continue;
files.push(
await item.node.getSafeEntry(),
);
files.push(await item.node.getSafeEntry());
}
}
const metadata = a.get('req').body.metadata || {};
svc_notification.notify(UsernameNotifSelector(username), {
source: 'sharing',
icon: 'shared.svg',
@@ -190,7 +183,6 @@ module.exports = new Sequence([
'with you.',
});
// Working on notifications
// Email should have a link to a shared file, right?
// .. how do I make those URLs? (gui feature)
@@ -204,23 +196,23 @@ module.exports = new Sequence([
message: metadata.message,
});
}
result.recipients[recipient_item.i] =
{ $: 'api:status-report', status: 'success' };
}
},
async function email_the_email_recipients (a) {
async function email_the_email_recipients(a) {
const { actor, recipients_work, shares_work } = a.values();
const svc_share = a.iget('services').get('share');
const svc_token = a.iget('services').get('token');
const svc_email = a.iget('services').get('email');
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'email' ) continue;
const email = recipient_item.value;
// data that gets stored in the `data` column of the share
const metadata = a.get('req').body.metadata || {};
const data = {
@@ -229,12 +221,12 @@ module.exports = new Sequence([
permissions: [],
metadata,
};
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
data.permissions.push(...permissions);
}
// track: scoping iife
const share_token = await (async () => {
const share_uid = await svc_share.create_share({
@@ -247,13 +239,13 @@ module.exports = new Sequence([
$v: '0.0.0',
uid: share_uid,
}, {
expiresIn: '14d'
expiresIn: '14d',
});
})();
const email_link =
`${config.origin}?share_token=${share_token}`;
await svc_email.send_email({ email }, 'share_by_email', {
link: email_link,
sender_name: actor.type.user.username,
@@ -261,9 +253,9 @@ module.exports = new Sequence([
});
}
},
function send_result (a) {
function send_result(a) {
const { res, result } = a.values();
result.serialize();
res.send(result);
}
},
]);

View File

@@ -21,15 +21,16 @@ const APIError = require("../../../api/APIError");
const { Sequence } = require("../../../codex/Sequence");
const config = require("../../../config");
const { get_user, get_app } = require("../../../helpers");
const { PermissionUtil } = require("../../../services/auth/PermissionUtils.mjs");
const { PermissionUtil } = require("../../../services/auth/permissionUtils.mjs");
const FSNodeParam = require("../../../api/filesystem/FSNodeParam");
const { TYPE_DIRECTORY } = require("../../../filesystem/FSNodeContext");
const { MANAGE_PERM_PREFIX } = require("../../../services/auth/permissionConts.mjs");
/*
This code is optimized for editors supporting folding.
Fold at Level 2 to conveniently browse sequence steps.
Fold at Level 3 after opening an inner-sequence.
If you're using VSCode {
typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
@@ -39,30 +40,30 @@ const { TYPE_DIRECTORY } = require("../../../filesystem/FSNodeContext");
module.exports = new Sequence({
name: 'process shares',
beforeEach (a) {
beforeEach(a) {
const { shares_work } = a.values();
shares_work.clear_invalid();
}
},
}, [
function validate_share_types (a) {
function validate_share_types(a) {
const { result, shares_work } = a.values();
const lib_typeTagged = a.iget('services').get('lib-type-tagged');
for ( const item of shares_work.list() ) {
const { i } = item;
let { value } = item;
const thing = lib_typeTagged.process(value);
if ( thing.$ === 'error' ) {
item.invalid = true;
result.shares[i] =
APIError.create('format_error', null, {
message: thing.message
message: thing.message,
});
continue;
}
const allowed_things = ['fs-share', 'app-share'];
if ( ! allowed_things.includes(thing.$) ) {
item.invalid = true;
@@ -73,11 +74,11 @@ module.exports = new Sequence({
});
continue;
}
item.thing = thing;
}
},
function create_file_share_intents (a) {
function create_file_share_intents(a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
const { thing } = item;
@@ -90,7 +91,7 @@ module.exports = new Sequence({
}
let access = thing.access;
if ( access ) {
if ( ! ['read','write'].includes(access) ) {
if ( ! ['read', 'write', MANAGE_PERM_PREFIX].includes(access) ) {
errors.push('`access` should be `read` or `write`');
}
} else access = 'read';
@@ -100,19 +101,19 @@ module.exports = new Sequence({
result.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
errors,
});
continue;
}
item.path = thing.path;
item.share_intent = {
$: 'share-intent:file',
permissions: [PermissionUtil.join('fs', thing.path, access)],
permissions: access === MANAGE_PERM_PREFIX ? [PermissionUtil.join(access, 'fs', thing.path)] : [PermissionUtil.join('fs', thing.path, access)],
};
}
},
function create_app_share_intents (a) {
function create_app_share_intents(a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
const { thing } = item;
@@ -129,46 +130,46 @@ module.exports = new Sequence({
result.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
errors,
});
continue;
}
const app_selector = thing.uid
? `uid#${thing.uid}` : thing.name;
item.share_intent = {
$: 'share-intent:app',
permissions: [
PermissionUtil.join('app', app_selector, 'access')
]
}
PermissionUtil.join('app', app_selector, 'access'),
],
};
continue;
}
},
async function fetch_nodes_for_file_shares (a) {
async function fetch_nodes_for_file_shares(a) {
const { req, result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
if ( item.type !== 'fs' ) continue;
const node = await (new FSNodeParam('path')).consolidate({
req, getParam: () => item.path
req, getParam: () => item.path,
});
if ( ! await node.exists() ) {
item.invalid = true;
result.shares[item.i] = APIError.create('subject_does_not_exist', {
path: item.path,
})
});
continue;
}
item.node = node;
let email_path = item.path;
let is_dir = true;
if ( await node.get('type') !== TYPE_DIRECTORY ) {
is_dir = false;
// remove last component
email_path = email_path.slice(0, item.path.lastIndexOf('/')+1);
email_path = email_path.slice(0, item.path.lastIndexOf('/') + 1);
}
if ( email_path.startsWith('/') ) email_path = email_path.slice(1);
@@ -177,10 +178,10 @@ module.exports = new Sequence({
item.email_link = email_link;
}
},
async function fetch_apps_for_app_shares (a) {
async function fetch_apps_for_app_shares(a) {
const { result, shares_work } = a.values();
const db = a.iget('db');
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const { thing } = item;
@@ -196,49 +197,45 @@ module.exports = new Sequence({
APIError.create('entity_not_found', null, {
identifier: thing.uid
? { uid: thing.uid }
: { id: { name: thing.name } }
: { id: { name: thing.name } },
});
}
app.metadata = db.case({
mysql: () => app.metadata,
otherwise: () => JSON.parse(app.metadata ?? '{}')
otherwise: () => JSON.parse(app.metadata ?? '{}'),
})();
item.app = app;
}
},
async function add_subdomain_permissions (a) {
async function add_subdomain_permissions(a) {
const { shares_work } = a.values();
const actor = a.get('actor');
const db = a.iget('db');
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const [subdomain] = await db.read(
`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
const [subdomain] = await db.read(`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
`AND user_id = ? LIMIT 1`,
[item.app.id, actor.type.user.id]
);
[item.app.id, actor.type.user.id]);
if ( ! subdomain ) continue;
// The subdomain is also owned by this user, so we'll
// add a permission for that as well
const site_selector = `uid#${subdomain.uuid}`;
item.share_intent.permissions.push(
PermissionUtil.join('site', site_selector, 'access')
)
item.share_intent.permissions.push(PermissionUtil.join('site', site_selector, 'access'));
}
},
async function add_appdata_permissions (a) {
const { result, shares_work } = a.values();
async function add_appdata_permissions(a) {
const { shares_work } = a.values();
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
if ( ! item.app.metadata?.shared_appdata ) continue;
const app_owner = await get_user({ id: item.app.owner_user_id });
const appdatadir =
`/${app_owner.username}/AppData/${item.app.uid}`;
const appdatadir_perm =
@@ -247,7 +244,7 @@ module.exports = new Sequence({
item.share_intent.permissions.push(appdatadir_perm);
}
},
function apply_success_status_to_shares (a) {
function apply_success_status_to_shares(a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
result.shares[item.i] =
@@ -256,9 +253,11 @@ module.exports = new Sequence({
status: 'success',
fields: {
permission: item.permission,
}
},
};
}
},
function return_state (a) { return a; }
function return_state(a) {
return a;
},
]);

View File

@@ -25,7 +25,7 @@ const { whatis } = require("../../../util/langutil");
This code is optimized for editors supporting folding.
Fold at Level 2 to conveniently browse sequence steps.
Fold at Level 3 after opening an inner-sequence.
If you're using VSCode {
typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
@@ -36,14 +36,12 @@ const { whatis } = require("../../../util/langutil");
module.exports = new Sequence({
name: 'validate request',
}, [
function validate_metadata (a) {
console.log('thinngggggg', a.get('thing'));
a.set('asdf', 'zxcv');
function validate_metadata(a) {
const req = a.get('req');
const metadata = req.body.metadata;
if ( ! metadata ) return;
if ( typeof metadata !== 'object' ) {
throw APIError.create('field_invalid', null, {
key: 'metadata',
@@ -54,7 +52,7 @@ module.exports = new Sequence({
const MAX_KEYS = 20;
const MAX_STRING = 255;
const MAX_MESSAGE_STRING = 10*1024;
const MAX_MESSAGE_STRING = 10 * 1024;
if ( Object.keys(metadata).length > MAX_KEYS ) {
throw APIError.create('field_invalid', null, {
@@ -99,10 +97,10 @@ module.exports = new Sequence({
}
}
},
function validate_mode (a) {
function validate_mode(a) {
const req = a.get('req');
const mode = req.body.mode;
if ( mode === 'strict' ) {
a.set('strict_mode', true);
return;
@@ -116,7 +114,7 @@ module.exports = new Sequence({
expected: '`strict`, `best-effort`, or undefined',
});
},
function validate_recipients (a) {
function validate_recipients(a) {
const req = a.get('req');
let recipients = req.body.recipients;
@@ -130,7 +128,7 @@ module.exports = new Sequence({
key: 'recipients',
expected: 'array or string',
got: typeof recipients,
})
});
}
// At least one recipient
if ( recipients.length < 1 ) {
@@ -142,14 +140,14 @@ module.exports = new Sequence({
}
a.set('req_recipients', recipients);
},
function validate_shares (a) {
function validate_shares(a) {
const req = a.get('req');
let shares = req.body.shares;
if ( ! Array.isArray(shares) ) {
shares = [shares];
}
// At least one share
if ( shares.length < 1 ) {
throw APIError.create('field_invalid', null, {
@@ -158,8 +156,10 @@ module.exports = new Sequence({
got: 'none',
});
}
a.set('req_shares', shares);
},
function return_state (a) { return a; }
function return_state(a) {
return a;
},
]);

View File

@@ -16,7 +16,7 @@
* 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/>.
*/
import UIWindow from './UIWindow.js'
import UIWindow from './UIWindow.js';
async function UIWindowShare(items, recipient){
return new Promise(async (resolve) => {
@@ -84,6 +84,7 @@ async function UIWindowShare(items, recipient){
h += `<select class="access-type" style="width: 170px; margin-bottom: 0; margin-right: 5px;">`;
h += `<option value="Viewer">${i18n('Viewer')}</option>`;
h += `<option value="Editor">${i18n('Editor')}</option>`;
h += `<option value="Manager">${i18n('Manager')}</option>`;
h += `</select>`;
// Share
@@ -189,6 +190,8 @@ async function UIWindowShare(items, recipient){
perm_list += `<span class="permission-viewer-badge">${i18n('Viewer')}</span>`;
else if(perm.access === 'write')
perm_list += `<span class="permission-editor-badge">${i18n('Editor')}</span>`;
else if(perm.access === 'manager')
perm_list += `<span class="permission-manager-badge">${i18n('Manager')}</span>`;
perm_list += `</div>`;
// username
perm_list += `${perm.user.email ?? perm.user.username}`;
@@ -264,6 +267,8 @@ async function UIWindowShare(items, recipient){
if($(el_window).find('.access-type').val() === 'Viewer')
access_level = 'read';
else if($(el_window).find('.access-type').val() === 'Manager')
access_level = 'manage';
$.ajax({
url: puter.APIOrigin + "/share",
@@ -296,7 +301,14 @@ async function UIWindowShare(items, recipient){
} else {
// show success message
$(el_window).find('.access-recipient-print').html(recipient_id);
let perm_id = `fs:${items[0].uid}:${access_level}`;
let perm_id;
if(access_level === 'manage'){
perm_id = `manage:fs:${items[0].uid}`;
}
else{
perm_id = `fs:${items[0].uid}:${access_level}`;
}
// append recipient to list
let perm_list = '';
@@ -306,7 +318,9 @@ async function UIWindowShare(items, recipient){
if(access_level === 'read')
perm_list += `<span class="permission-viewer-badge">${i18n('Viewer')}</span>`;
else if(access_level === 'write')
perm_list += `<span class="permission-editor-badge">i18n('Viewer')</span>`;
perm_list += `<span class="permission-editor-badge">${i18n('Editor')}</span>`;
else if(access_level === 'manage')
perm_list += `<span class="permission-manager-badge">${i18n('Manager')}</span>`;
perm_list += `</div>`;
// recipient username
perm_list += `${recipient_username}`;