diff --git a/src/backend/src/routers/auth/grant-dev-app.js b/src/backend/src/routers/auth/grant-dev-app.js new file mode 100644 index 00000000..2af76463 --- /dev/null +++ b/src/backend/src/routers/auth/grant-dev-app.js @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require("../../api/APIError"); +const eggspress = require("../../api/eggspress"); +const { UserActorType } = require("../../services/auth/Actor"); +const { Context } = require("../../util/context"); + +module.exports = eggspress('/auth/grant-dev-app', { + subdomain: 'api', + auth2: true, + allowedMethods: ['POST'], +}, async (req, res, next) => { + const x = Context.get(); + const svc_permission = x.get('services').get('permission'); + + // Only users can grant user-app permissions + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + if ( req.body.origin ) { + const svc_auth = x.get('services').get('auth'); + req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin); + } + + if ( ! req.body.app_uid ) { + throw APIError.create('field_missing', null, { key: 'app_uid' }); + } + + if ( ! req.body.permission ) { + throw APIError.create('field_missing', null, { + key: 'permission' + }); + } + + await svc_permission.grant_dev_app_permission( + actor, req.body.app_uid, req.body.permission, + req.body.extra || {}, req.body.meta || {} + ); + + res.json({}); +}); + + diff --git a/src/backend/src/routers/auth/revoke-dev-app.js b/src/backend/src/routers/auth/revoke-dev-app.js new file mode 100644 index 00000000..9f30a8a2 --- /dev/null +++ b/src/backend/src/routers/auth/revoke-dev-app.js @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const eggspress = require("../../api/eggspress"); +const { UserActorType } = require("../../services/auth/Actor"); +const { Context } = require("../../util/context"); +const APIError = require('../../api/APIError'); + +module.exports = eggspress('/auth/revoke-dev-app', { + subdomain: 'api', + auth2: true, + allowedMethods: ['POST'], +}, async (req, res, next) => { + const x = Context.get(); + const svc_permission = x.get('services').get('permission'); + + // Only users can grant user-app permissions + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + throw APIError.create('forbidden'); + } + + if ( req.body.origin ) { + const svc_auth = x.get('services').get('auth'); + req.body.app_uid = await svc_auth.app_uid_from_origin(req.body.origin); + } + + if ( ! req.body.app_uid ) { + throw APIError.create('field_missing', null, { key: 'app_uid' }); + } + + if ( req.body.permission === '*' ) { + await svc_permission.revoke_dev_app_all( + actor, req.body.app_uid, req.body.meta || {}, + ); + } + + await svc_permission.revoke_dev_app_permission( + actor, req.body.app_uid, req.body.permission, + req.body.meta || {}, + ); + + res.json({}); +}); + + + diff --git a/src/backend/src/services/HostnameService.js b/src/backend/src/services/HostnameService.js new file mode 100644 index 00000000..5f5153a0 --- /dev/null +++ b/src/backend/src/services/HostnameService.js @@ -0,0 +1,36 @@ +const BaseService = require("./BaseService"); + +const os = require('os'); + +class HostnameService extends BaseService { + _construct () { + this.entries = {}; + } + + _init () { + if ( this.global_config.domain ) { + this.entries[this.global_config.domain] = { + scope: 'web', + }; + this.entries[`api.${this.global_config.domain}`] = { + scope: 'api', + }; + } + + const addresses = this.get_broadcast_addresses(); + + if ( ! this.global_config.no_nip ) { + // + } + } + + get_broadcast_addresses () { + const ifaces = os.networkInterfaces(); + + for ( const iface_key in ifaces ) { + console.log('iface_key', iface_key); + } + } +} + +module.exports = { HostnameService }; diff --git a/src/backend/src/services/PermissionAPIService.js b/src/backend/src/services/PermissionAPIService.js index 4b94bee2..9c28ef30 100644 --- a/src/backend/src/services/PermissionAPIService.js +++ b/src/backend/src/services/PermissionAPIService.js @@ -49,6 +49,8 @@ class PermissionAPIService extends BaseService { 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')); diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js index 73db21a1..ea375fe9 100644 --- a/src/backend/src/services/auth/PermissionService.js +++ b/src/backend/src/services/auth/PermissionService.js @@ -450,6 +450,160 @@ class PermissionService extends BaseService { } + /** + * Grants an app a permission for any user, as long as the user granting the + * permission also has the 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. + * @throws {Error} If the user to grant permission to is not found or if attempting to grant permissions to oneself. + * @returns {Promise} + */ + async grant_dev_app_permission (actor, app_uid, permission, extra = {}, meta) { + permission = await this._rewrite_permission(permission); + + let app = await get_app({ uid: app_uid }); + if ( ! app ) app = await get_app({ name: app_uid }); + + if ( ! app ) { + throw APIError.create('entity_not_found', null, { + identifier: 'app:' + app_uid, + }); + } + + const app_id = app.id; + + // UPSERT permission + await this.db.write( + 'INSERT INTO `dev_to_app_permissions` (`user_id`, `app_id`, `permission`, `extra`) ' + + 'VALUES (?, ?, ?, ?) ' + + this.db.case({ + mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?', + otherwise: 'ON CONFLICT(`user_id`, `app_id`, `permission`) DO UPDATE SET `extra` = ?', + }), + [ + actor.type.user.id, + app_id, + permission, + JSON.stringify(extra), + JSON.stringify(extra), + ] + ); + + // INSERT audit table + const audit_values = { + user_id: actor.type.user.id, + user_id_keep: actor.type.user.id, + app_id: app_id, + app_id_keep: app_id, + permission, + action: 'grant', + reason: meta?.reason || 'granted via PermissionService', + }; + + const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', '); + const sql_vals = Object.keys(audit_values).map((key) => `?`).join(', '); + + await this.db.write( + 'INSERT INTO `audit_dev_to_app_permissions` (' + sql_cols + ') ' + + 'VALUES (' + sql_vals + ')', + Object.values(audit_values) + ); + } + async revoke_dev_app_permission (actor, app_uid, permission, meta) { + permission = await this._rewrite_permission(permission); + + // For now, actor MUST be a user + if ( ! (actor.type instanceof UserActorType) ) { + throw new Error('actor must be a user'); + } + + let app = await get_app({ uid: app_uid }); + if ( ! app ) app = await get_app({ name: app_uid }); + if ( ! app ) { + throw APIError.create('entity_not_found', null, { + identifier: 'app' + app_uid, + }) + } + const app_id = app.id; + + // DELETE permission + await this.db.write( + 'DELETE FROM `dev_to_app_permissions` ' + + 'WHERE `user_id` = ? AND `app_id` = ? AND `permission` = ?', + [ + actor.type.user.id, + app_id, + permission, + ] + ); + + // INSERT audit table + const audit_values = { + user_id: actor.type.user.id, + user_id_keep: actor.type.user.id, + app_id: app_id, + app_id_keep: app_id, + permission, + action: 'revoke', + reason: meta?.reason || 'revoked via PermissionService', + }; + + const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', '); + const sql_vals = Object.keys(audit_values).map((key) => `?`).join(', '); + + await this.db.write( + 'INSERT INTO `audit_dev_to_app_permissions` (' + sql_cols + ') ' + + 'VALUES (' + sql_vals + ')', + Object.values(audit_values) + ); + } + async revoke_dev_app_all (actor, app_uid, meta) { + // For now, actor MUST be a user + if ( ! (actor.type instanceof UserActorType) ) { + throw new Error('actor must be a user'); + } + + let app = await get_app({ uid: app_uid }); + if ( ! app ) app = await get_app({ name: app_uid }); + const app_id = app.id; + + // DELETE permissions + await this.db.write( + 'DELETE FROM `dev_to_app_permissions` ' + + 'WHERE `user_id` = ? AND `app_id` = ?', + [ + actor.type.user.id, + app_id, + ] + ); + + // INSERT audit table + const audit_values = { + user_id: actor.type.user.id, + user_id_keep: actor.type.user.id, + app_id: app_id, + app_id_keep: app_id, + permission: '*', + action: 'revoke', + reason: meta?.reason || 'revoked all via PermissionService', + }; + + const sql_cols = Object.keys(audit_values).map((key) => `\`${key}\``).join(', '); + const sql_vals = Object.keys(audit_values).map((key) => `?`).join(', '); + + await this.db.write( + 'INSERT INTO `audit_dev_to_app_permissions` (' + sql_cols + ') ' + + 'VALUES (' + sql_vals + ')', + Object.values(audit_values) + ); + } + + /** * Grants a permission to a user for a specific app. * diff --git a/src/backend/src/services/database/SqliteDatabaseAccessService.js b/src/backend/src/services/database/SqliteDatabaseAccessService.js index df02c7e7..85e880d0 100644 --- a/src/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/src/backend/src/services/database/SqliteDatabaseAccessService.js @@ -157,7 +157,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { ]], [32, [ '0035_threads.sql', - ]] + ]], + [33, [ + '0036_dev-to-app.sql', + ]], ]; // Database upgrade logic diff --git a/src/backend/src/services/database/sqlite_setup/0036_dev-to-app.sql b/src/backend/src/services/database/sqlite_setup/0036_dev-to-app.sql new file mode 100644 index 00000000..6ccfaded --- /dev/null +++ b/src/backend/src/services/database/sqlite_setup/0036_dev-to-app.sql @@ -0,0 +1,31 @@ +CREATE TABLE `dev_to_app_permissions` ( + `user_id` int(10) NOT NULL, + `app_id` int(10) NOT NULL, + `permission` varchar(255) NOT NULL, + `extra` JSON DEFAULT NULL, + + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (`user_id`, `app_id`, `permission`) +); + +CREATE TABLE `audit_dev_to_app_permissions` ( + `id` INTEGER PRIMARY KEY, + + `user_id` int(10) DEFAULT NULL, + `user_id_keep` int(10) NOT NULL, + + `app_id` int(10) DEFAULT NULL, + `app_id_keep` int(10) NOT NULL, + + `permission` varchar(255) NOT NULL, + `extra` JSON DEFAULT NULL, + + `action` VARCHAR(16) DEFAULT NULL, -- "granted" or "revoked" + `reason` VARCHAR(255) DEFAULT NULL, + + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`app_id`) REFERENCES `apps` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +); \ No newline at end of file diff --git a/src/backend/src/unstructured/permission-scanners.js b/src/backend/src/unstructured/permission-scanners.js index 7572fa5c..84d5388d 100644 --- a/src/backend/src/unstructured/permission-scanners.js +++ b/src/backend/src/unstructured/permission-scanners.js @@ -401,6 +401,58 @@ const PERMISSION_SCANNERS = [ } } }, + { + name: 'user-app', + documentation: ` + If the actor is an app, this scans for permissions granted to the app + because any other user has the permission and granted it to the app + for all users of the app. + `, + async scan (a) { + const { reading, actor, permission_options } = a.values(); + if ( !(actor.type instanceof AppUnderUserActorType) ) { + return; + } + const db = a.iget('db'); + + let sql_perm = permission_options.map(() => + `\`permission\` = ?`).join(' OR '); + if ( permission_options.length > 1 ) sql_perm = '(' + sql_perm + ')'; + + // SELECT permission + const rows = await db.read( + 'SELECT * FROM `dev_to_app_permissions` ' + + 'WHERE `app_id` = ? AND ' + + sql_perm, + [ + actor.type.user.id, + actor.type.app.id, + ...permission_options, + ] + ); + + if ( rows[0] ) { + const row = rows[0]; + row.extra = db.case({ + mysql: () => row.extra, + otherwise: () => JSON.parse(row.extra ?? '{}') + })(); + const issuer_user = await get_user({ id: row.user_id }); + const issuer_actor = Actor.adapt(issuer_user); + const issuer_reading = await a.icall('scan', issuer_actor, row.permission); + const has_terminal = reading_has_terminal({ reading: issuer_reading }); + reading.push({ + $: 'path', + via: 'dev-app', + permission: row.permission, + has_terminal, + data: row.extra, + issuer_username: actor.type.user.username, + reading: issuer_reading, + }); + } + } + }, ]; module.exports = { diff --git a/src/puter-js/src/modules/Perms.js b/src/puter-js/src/modules/Perms.js index 3a693226..e859ebba 100644 --- a/src/puter-js/src/modules/Perms.js +++ b/src/puter-js/src/modules/Perms.js @@ -38,6 +38,12 @@ export default class Perms { }) } + async grantAppAnyUser (app_uid, permission) { + return await this.req_('/auth/grant-dev-app', { + app_uid, permission, + }) + } + async grantOrigin (origin, permission) { return await this.req_('/auth/grant-user-app', { origin, permission, @@ -63,6 +69,12 @@ export default class Perms { }) } + async revokeAppAnyUser (app_uid, permission) { + return await this.req_('/auth/revoke-dev-app', { + app_uid, permission, + }) + } + async revokeOrigin (origin, permission) { return await this.req_('/auth/revoke-user-app', { origin, permission,