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,