Explorar o código

Merge pull request #231 from HeyPuter/eric/user-to-user-permissions

User-to-User Permission Granting
Eric Dubé hai 1 ano
pai
achega
c6fb75c65f

+ 1 - 1
packages/backend/src/codex/Sequence.js

@@ -109,7 +109,7 @@ class Sequence {
         async run (values) {
             // Initialize scope
             values = values || this.thisArg?.values || {};
-            Object.assign(this.scope_, values);
+            Object.assign(this.scope_, values); // TODO: can this be __proto__?
 
             // Run sequence
             for ( ; this.i < this.steps.length ; this.i++ ) {

+ 36 - 1
packages/backend/src/filesystem/FilesystemService.js

@@ -34,8 +34,10 @@ 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 } = require('../services/auth/PermissionService.js');
+const { PermissionUtil, PermissionRewriter, PermissionImplicator } = require('../services/auth/PermissionService.js');
 const { DB_WRITE } = require("../services/database/consts");
+const { UserActorType } = require('../services/auth/Actor');
+const { get_user } = require('../helpers');
 
 class FilesystemService extends AdvancedBase {
     static MODULES = {
@@ -131,6 +133,39 @@ class FilesystemService extends AdvancedBase {
                 return `fs:${uid}:${rest.join(':')}`;
             },
         }));
+        svc_permission.register_implicator(PermissionImplicator.create({
+            matcher: permission => {
+                return permission.startsWith('fs:');
+            },
+            checker: async (actor, permission) => {
+                debugger;
+                if ( !(actor.type instanceof UserActorType) ) {
+                    return undefined;
+                }
+
+                const [_, uid] = PermissionUtil.split(permission);
+                const node = await this.node(new NodeUIDSelector(uid));
+
+                if ( ! await node.exists() ) {
+                    return undefined;
+                }
+
+                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'
+                    );
+                }
+
+                if ( owner_id === actor.type.user.id ) {
+                    return {};
+                }
+
+                return undefined;
+            },
+        }));
     }
 
     /**

+ 1 - 1
packages/backend/src/filesystem/hl_operations/hl_stat.js

@@ -45,7 +45,7 @@ class HLStat extends HLFilesystemOperation {
         const svc_acl = context.get('services').get('acl');
         const actor = context.get('actor');
         if ( ! await svc_acl.check(actor, subject, 'read') ) {
-            throw await svc_acl.get_safe_acl_error(actor, subject.entry, 'read');
+            throw await svc_acl.get_safe_acl_error(actor, subject, 'read');
         }
 
         // check permission

+ 30 - 0
packages/backend/src/routers/auth/grant-user-user.js

@@ -0,0 +1,30 @@
+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-user-user', {
+    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-user permissions
+    const actor = Context.get('actor');
+    if ( ! (actor.type instanceof UserActorType) ) {
+        throw APIError.create('forbidden');
+    }
+
+    if ( ! req.body.target_username ) {
+        throw APIError.create('field_missing', null, { key: 'target_username' });
+    }
+
+    await svc_permission.grant_user_user_permission(
+        actor, req.body.target_username, req.body.permission,
+        req.body.extra || {}, req.body.meta || {}
+    );
+
+    res.json({});
+});

+ 31 - 0
packages/backend/src/routers/auth/revoke-user-user.js

@@ -0,0 +1,31 @@
+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/revoke-user-user', {
+    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-user permissions
+    const actor = Context.get('actor');
+    if ( ! (actor.type instanceof UserActorType) ) {
+        throw APIError.create('forbidden');
+    }
+
+    if ( ! req.body.target_username ) {
+        throw APIError.create('field_missing', null, { key: 'target_username' });
+    }
+
+    await svc_permission.revoke_user_user_permission(
+        actor, req.body.target_username, req.body.permission,
+        req.body.meta || {}
+    );
+
+    res.json({});
+});
+

+ 2 - 0
packages/backend/src/services/PuterAPIService.js

@@ -30,6 +30,8 @@ class PuterAPIService 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-user-user'));
+        app.use(require('../routers/auth/revoke-user-user'));
         app.use(require('../routers/auth/list-permissions'))
         app.use(require('../routers/auth/check-app'))
         app.use(require('../routers/auth/app-uid-from-origin'))

+ 1 - 29
packages/backend/src/services/auth/ACLService.js

@@ -76,35 +76,6 @@ class ACLService extends BaseService {
             }
         }
 
-        // Hard rule: if actor is owner, allow
-        if ( actor.type instanceof UserActorType ) {
-            const owner = await fsNode.get('user_id');
-            if ( this.verbose ) {
-                const user = await get_user({ id: owner });
-                this.log.info(
-                    `user ${user.username} is ` +
-                    (owner == actor.type.user.id ? '' : 'not ') +
-                    'owner of ' + await fsNode.get('path'), {
-                        actor_user_id: actor.type.user.id,
-                        fsnode_user_id: owner,
-                    }
-                );
-            }
-            if ( owner == actor.type.user.id ) {
-                return true;
-            }
-        }
-
-        // For these actors, deny if the user component is not the owner
-        // -> user
-        // -> app-under-user
-        if ( ! (actor.type instanceof AccessTokenActorType) ) {
-            const owner = await fsNode.get('user_id');
-            if ( owner != actor.type.user.id ) {
-                return false;
-            }
-        }
-
         // app-under-user only works if the user also has permission
         if ( actor.type instanceof AppUnderUserActorType ) {
             const user_actor = new Actor({
@@ -146,6 +117,7 @@ class ACLService extends BaseService {
         return APIError.create('forbidden');
     }
 
+    // TODO: DRY: Also in FilesystemService
     _higher_modes (mode) {
         // If you want to X, you can do so with any of [...Y]
         if ( mode === 'see' ) return ['see', 'list', 'read', 'write'];

+ 208 - 22
packages/backend/src/services/auth/PermissionService.js

@@ -84,6 +84,10 @@ const implicit_user_app_permissions = [
     },
 ];
 
+const implicit_user_permissions = {
+    'driver': {},
+};
+
 class PermissionRewriter {
     static create ({ id, matcher, rewriter }) {
         return new PermissionRewriter({ id, matcher, rewriter });
@@ -104,6 +108,32 @@ class PermissionRewriter {
     }
 }
 
+class PermissionImplicator {
+    static create ({ id, matcher, checker }) {
+        return new PermissionImplicator({ id, matcher, checker });
+    }
+
+    constructor ({ id, matcher, checker }) {
+        this.id = id;
+        this.matcher = matcher;
+        this.checker = checker;
+    }
+
+    matches (permission) {
+        return this.matcher(permission);
+    }
+
+    /**
+     * Check if the permission is implied by this implicator
+     * @param  {Actor} actor
+     * @param  {string} permission
+     * @returns 
+     */
+    async check (actor, permission) {
+        return await this.checker(actor, permission);
+    }
+}
+
 class PermissionUtil {
     static unescape_permission_component (component) {
         let unescaped_str = '';
@@ -142,6 +172,7 @@ class PermissionService extends BaseService {
         this._register_commands(this.services.get('commands'));
 
         this._permission_rewriters = [];
+        this._permission_implicators = [];
     }
 
     async _rewrite_permission (permission) {
@@ -162,30 +193,86 @@ class PermissionService extends BaseService {
         });
         // For now we're only checking driver permissions, and users have all of them
         if ( actor.type instanceof UserActorType ) {
-            return {};
+            return await this.check_user_permission(actor, permission);
         }
 
         if ( actor.type instanceof AccessTokenActorType ) {
+            // Authorizer must have permission
+            const authorizer_permission = await this.check(authorizer, permission);
+            if ( ! authorizer_permission ) return false;
+
             return await this.check_access_token_permission(
                 actor.type.authorizer, actor.type.token, permission
             );
         }
 
         // Prevent undefined behaviour
-        if ( ! (actor.type instanceof AppUnderUserActorType) ) {
-            throw new Error('actor must be an app under a user');
+        if ( actor.type instanceof AppUnderUserActorType ) {
+            // NEXT:
+            const app_uid = actor.type.app.uid;
+            const user_has_permission = await this.check_user_permission(actor, permission);
+            if ( ! user_has_permission ) return undefined;
+
+            return await this.check_user_app_permission(actor, app_uid, permission);
         }
 
-        // Now it's an app under a user
-        const app_uid = actor.type.app.uid;
-        return await this.check_user_app_permission(actor, app_uid, permission);
+        throw new Error('unrecognized actor type');
     }
 
-    async check_access_token_permission (authorizer, token, permission) {
-        // Authorizer must have permission
-        const authorizer_permission = await this.check(authorizer, permission);
-        if ( ! authorizer_permission ) return false;
+    // TODO: context meta for cycle detection
+    async check_user_permission (actor, permission) {
+        permission = await this._rewrite_permission(permission);
+        const parent_perms = this.get_parent_permissions(permission);
+
+        // Check implicit permissions
+        for ( const parent_perm of parent_perms ) {
+            if ( implicit_user_permissions[parent_perm] ) {
+                return implicit_user_permissions[parent_perm];
+            }
+        }
+
+        for ( const implicator of this._permission_implicators ) {
+            if ( ! implicator.matches(permission) ) continue;
+            const implied = await implicator.check(actor, permission);
+            if ( implied ) return implied;
+        }
+
+        // Check permissions granted by other users
+        let sql_perm = parent_perms.map((perm) =>
+            `\`permission\` = ?`).join(' OR ');
+        if ( parent_perms.length > 1 ) sql_perm = '(' + sql_perm + ')';
 
+        // SELECT permission
+        const rows = await this.db.read(
+            'SELECT * FROM `user_to_user_permissions` ' +
+            'WHERE `holder_user_id` = ? AND ' +
+            sql_perm,
+            [
+                actor.type.user.id,
+                ...parent_perms,
+            ]
+        );
+
+        // Return the first matching permission where the
+        // issuer also has the permission granted
+        for ( const row of rows ) {
+            const issuer_actor = new Actor({
+                type: new UserActorType({
+                    user: await get_user({ id: row.issuer_user_id }),
+                }),
+            });
+
+            const issuer_perm = await this.check(issuer_actor, row.permission);
+
+            if ( ! issuer_perm ) continue;
+
+            return row.extra;
+        }
+        
+        return undefined;
+    }
+
+    async check_access_token_permission (authorizer, token, permission) {
         const rows = await this.db.read(
             'SELECT * FROM `access_token_permissions` ' +
             'WHERE `token_uid` = ? AND `permission` = ?',
@@ -208,18 +295,7 @@ class PermissionService extends BaseService {
         if ( ! app ) app = await get_app({ name: app_uid });
         const app_id = app.id;
 
-        const parent_perms = [];
-        {
-            // We don't use PermissionUtil.split here because it unescapes
-            // components; we want to keep the components escaped for matching.
-            const parts = permission.split(':');
-
-            // Add sub-permissions
-            for ( let i = 1 ; i < parts.length ; i++ ) {
-                parent_perms.push(parts.slice(0, i + 1).join(':'));
-            }
-        }
-        parent_perms.reverse();
+        const parent_perms = this.get_parent_permissions(permission);
 
         for ( const permission of parent_perms ) {
             // Check hardcoded permissions
@@ -394,6 +470,107 @@ class PermissionService extends BaseService {
         );
     }
 
+    async grant_user_user_permission (actor, username, permission, extra = {}, meta) {
+        permission = await this._rewrite_permission(permission);
+        const user = await get_user({ username });
+        if ( ! user ) {
+            throw new Error('user not found');
+        }
+
+        // Don't allow granting permissions to yourself
+        if ( user.id === actor.type.user.id ) {
+            throw new Error('cannot grant permissions to yourself');
+        }
+
+        // UPSERT permission
+        await this.db.write(
+            'INSERT INTO `user_to_user_permissions` (`holder_user_id`, `issuer_user_id`, `permission`, `extra`) ' +
+            'VALUES (?, ?, ?, ?) ' +
+            this.db.case({
+                mysql: 'ON DUPLICATE KEY UPDATE `extra` = ?',
+                otherwise: 'ON CONFLICT(`holder_user_id`, `issuer_user_id`, `permission`) DO UPDATE SET `extra` = ?',
+            }),
+            [
+                user.id,
+                actor.type.user.id,
+                permission,
+                JSON.stringify(extra),
+                JSON.stringify(extra),
+            ]
+        );
+
+        // INSERT audit table
+        await 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 (?, ?, ?, ?, ?, ?, ?)',
+            [
+                user.id,
+                user.id,
+                actor.type.user.id,
+                actor.type.user.id,
+                permission,
+                'grant',
+                meta?.reason || 'granted via PermissionService',
+            ]
+        );
+    }
+
+    async revoke_user_user_permission (actor, username, permission, meta) {
+        permission = await this._rewrite_permission(permission);
+
+        const user = await get_user({ username });
+        if ( ! user ) {
+            throw new Error('user not found');
+        }
+
+        // DELETE permission
+        await this.db.write(
+            'DELETE FROM `user_to_user_permissions` ' +
+            'WHERE `holder_user_id` = ? AND `issuer_user_id` = ? AND `permission` = ?',
+            [
+                user.id,
+                actor.type.user.id,
+                permission,
+            ]
+        );
+
+        // INSERT audit table
+        await 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 (?, ?, ?, ?, ?, ?, ?)',
+            [
+                user.id,
+                user.id,
+                actor.type.user.id,
+                actor.type.user.id,
+                permission,
+                'revoke',
+                meta?.reason || 'revoked via PermissionService',
+            ]
+        );
+    }
+
+    get_parent_permissions (permission) {
+        const parent_perms = [];
+        {
+            // We don't use PermissionUtil.split here because it unescapes
+            // components; we want to keep the components escaped for matching.
+            const parts = permission.split(':');
+
+            // Add sub-permissions
+            for ( let i = 0 ; i < parts.length ; i++ ) {
+                parent_perms.push(parts.slice(0, i + 1).join(':'));
+            }
+        }
+        parent_perms.reverse();
+        return parent_perms;
+    }
+
+
     register_rewriter (translator) {
         if ( ! (translator instanceof PermissionRewriter) ) {
             throw new Error('translator must be a PermissionRewriter');
@@ -402,6 +579,14 @@ class PermissionService extends BaseService {
         this._permission_rewriters.push(translator);
     }
 
+    register_implicator (implicator) {
+        if ( ! (implicator instanceof PermissionImplicator) ) {
+            throw new Error('implicator must be a PermissionImplicator');
+        }
+
+        this._permission_implicators.push(implicator);
+    }
+
     _register_commands (commands) {
         commands.registerCommands('perms', [
             {
@@ -425,6 +610,7 @@ class PermissionService extends BaseService {
 
 module.exports = {
     PermissionRewriter,
+    PermissionImplicator,
     PermissionUtil,
     PermissionService,
 };

+ 66 - 6
packages/backend/src/services/database/SqliteDatabaseAccessService.js

@@ -16,6 +16,8 @@
  * 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 { es_import_promise } = require("../../fun/dev-console-ui-utils");
+const { surrounding_box } = require("../../fun/dev-console-ui-utils");
 const { BaseDatabaseAccessService } = require("./BaseDatabaseAccessService");
 
 class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
@@ -40,23 +42,68 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
         this.db = new Database(this.config.path);
 
         if ( do_setup ) {
+            this.log.noticeme(`SETUP: creating database at ${this.config.path}`);
             const sql_files = [
                 '0001_create-tables.sql',
                 '0002_add-default-apps.sql',
+                '0003_user-permissions.sql',
             ].map(p => path_.join(__dirname, 'sqlite_setup', p));
             const fs = require('fs');
             for ( const filename of sql_files ) {
+                const basename = path_.basename(filename);
+                this.log.noticeme(`applying ${basename}`);
                 const contents = fs.readFileSync(filename, 'utf8');
                 this.db.exec(contents);
             }
         }
 
-        // Create the tables if they don't exist.
-        const check =
-            `SELECT name FROM sqlite_master WHERE type='table' AND name='fsentries'`;
-        const rows = await this.db.prepare(check).all();
-        if ( rows.length === 0 ) {
-            throw new Error('it works');
+        // Database upgrade logic
+        const TARGET_VERSION = 1;
+
+        const [{ user_version }] = await this._read('PRAGMA user_version');
+        this.log.info('database version: ' + user_version);
+
+        const upgrade_files = [];
+
+        if ( user_version <= 0 ) {
+            upgrade_files.push('0003_user-permissions.sql');
+        }
+
+        if ( upgrade_files.length > 0 ) {
+            this.log.noticeme(`Database out of date: ${this.config.path}`);
+            this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`);
+            this.log.noticeme(`${upgrade_files.length} .sql files to apply`);
+
+            const sql_files = upgrade_files.map(
+                p => path_.join(__dirname, 'sqlite_setup', p)
+            );
+            const fs = require('fs');
+            for ( const filename of sql_files ) {
+                const basename = path_.basename(filename);
+                this.log.noticeme(`applying ${basename}`);
+                const contents = fs.readFileSync(filename, 'utf8');
+                this.db.exec(contents);
+            }
+
+            // Update version number
+            await this.db.exec(`PRAGMA user_version = ${TARGET_VERSION};`);
+
+            // Add sticky notification
+            this.database_update_notice = () => {
+                const lines = [
+                    `Database has been updated!`,
+                    `Current version: ${TARGET_VERSION}`,
+                    `Type sqlite:dismiss to dismiss this message`,
+                ];
+                surrounding_box('33;1', lines);
+                return lines;
+            };
+
+            (async () => {
+                await es_import_promise;
+                const svc_devConsole = this.services.get('dev-console');
+                svc_devConsole.add_widget(this.database_update_notice);
+            })();
         }
     }
 
@@ -148,6 +195,19 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
                     }
                 }
             },
+            {
+                id: 'dismiss',
+                description: 'dismiss the database update notice',
+                handler: async (_, log) => {
+                    const svc_devConsole = this.services.get('dev-console');
+                    if ( ! svc_devConsole ) return;
+                    if ( ! this.database_update_notice ) return;
+                    svc_devConsole.remove_widget(this.database_update_notice);
+                    const lines = this.database_update_notice();
+                    for ( const line of lines ) log.log(line);
+                    this.database_update_notice = null;
+                }
+            }
         ])
     }
 }

+ 31 - 0
packages/backend/src/services/database/sqlite_setup/0003_user-permissions.sql

@@ -0,0 +1,31 @@
+CREATE TABLE `user_to_user_permissions` (
+    "issuer_user_id" INTEGER NOT NULL,
+    "holder_user_id" INTEGER NOT NULL,
+    "permission" TEXT NOT NULL,
+    "extra" JSON DEFAULT NULL,
+
+    FOREIGN KEY("issuer_user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+    FOREIGN KEY("holder_user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+    PRIMARY KEY ("issuer_user_id", "holder_user_id", "permission")
+);
+
+CREATE TABLE "audit_user_to_user_permissions" (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+
+    "issuer_user_id" INTEGER NOT NULL,
+    "issuer_user_id_keep" INTEGER DEFAULT NULL,
+
+    "holder_user_id" INTEGER NOT NULL,
+    "holder_user_id_keep" INTEGER DEFAULT NULL,
+
+    "permission" TEXT NOT NULL,
+    "extra" JSON DEFAULT NULL,
+
+    "action" TEXT DEFAULT NULL,
+    "reason" TEXT DEFAULT NULL,
+
+    "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    FOREIGN KEY("issuer_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+    FOREIGN KEY("holder_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);