Sfoglia il codice sorgente

Allow granting user-to-user permissions

KernelDeimos 1 anno fa
parent
commit
fc6eda392b

+ 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

+ 1 - 0
packages/backend/src/filesystem/ll_operations/ll_read.js

@@ -60,6 +60,7 @@ class LLRead extends LLFilesystemOperation {
                 const svc_acl = context.get('services').get('acl');
                 const { fsNode, actor } = a.values();
                 if ( ! await svc_acl.check(actor, fsNode, 'read') ) {
+                    console.log('\x1B[36;1mACL CHECK FAILED', { actor, fsNode });
                     throw await svc_acl.get_safe_acl_error(actor, fsNode, 'read');
                 }
             },

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

+ 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'))

+ 18 - 27
packages/backend/src/services/auth/ACLService.js

@@ -77,33 +77,23 @@ 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;
-            }
-        }
+        // 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;
+        //     }
+        // }
 
         // app-under-user only works if the user also has permission
         if ( actor.type instanceof AppUnderUserActorType ) {
@@ -146,6 +136,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'];

+ 180 - 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,89 @@ 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) {
+        this.log.noticeme('check input: ' +  permission);
+        permission = await this._rewrite_permission(permission);
+        this.log.noticeme('check output: ' +  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);
+
+            this.log.noticeme('issuer_perm', { row, issuer_perm });
+            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 +298,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 +473,76 @@ class PermissionService extends BaseService {
         );
     }
 
+    async grant_user_user_permission (actor, username, permission, extra = {}, meta) {
+        this.log.noticeme('input permission: ' + permission);
+        permission = await this._rewrite_permission(permission);
+        this.log.noticeme('output permission: ' + permission);
+        this.log.noticeme('fields', {
+            one_thing: 1,
+            another: 2
+        });
+        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',
+            ]
+        );
+    }
+
+    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 +551,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 +582,7 @@ class PermissionService extends BaseService {
 
 module.exports = {
     PermissionRewriter,
+    PermissionImplicator,
     PermissionUtil,
     PermissionService,
 };

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

@@ -4,8 +4,8 @@ CREATE TABLE `user_to_user_permissions` (
     "permission" TEXT NOT NULL,
     "extra" JSON DEFAULT NULL,
 
-    FOREIGN KEY("issuer_user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
-    FOREIGN KEY("holder_user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+    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")
 );
 
@@ -26,6 +26,6 @@ CREATE TABLE "audit_user_to_user_permissions" (
 
     "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 
-    FOREIGN KEY("issuer_user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
-    FOREIGN KEY("holder_user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+    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
 );