dev: begin adding developer app permissions

This commit is contained in:
KernelDeimos
2025-03-07 18:06:17 -05:00
parent 33c304879a
commit daa3449f29
9 changed files with 414 additions and 1 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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({});
});

View File

@@ -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 <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/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({});
});

View File

@@ -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 };

View File

@@ -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'));

View File

@@ -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<void>}
*/
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.
*

View File

@@ -157,7 +157,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
]],
[32, [
'0035_threads.sql',
]]
]],
[33, [
'0036_dev-to-app.sql',
]],
];
// Database upgrade logic

View File

@@ -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
);

View File

@@ -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 = {

View File

@@ -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,