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

User-to-User Permission Granting
This commit is contained in:
Eric Dubé 2024-04-05 23:11:32 -04:00 committed by GitHub
commit c6fb75c65f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 407 additions and 60 deletions

View File

@ -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++ ) {

View File

@ -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;
},
}));
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
}
// Now it's 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);
}
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;
throw new Error('unrecognized actor type');
}
// 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,
};

View File

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

View File

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