From 5b19b912c2e5ca00377e002e4240c9e9b9f052f7 Mon Sep 17 00:00:00 2001 From: Philipp Kraft Date: Tue, 28 Nov 2023 16:24:59 +0100 Subject: [PATCH] #90 - File access by project working, introduced Level-Enum for authorization --- odmf/db/person.py | 6 + odmf/db/project.py | 10 +- odmf/static/templates/download.html | 76 +++++++------ odmf/static/templates/project.html | 25 ++-- odmf/tools/__init__.py | 40 +++++-- odmf/webpage/auth.py | 56 +++++---- odmf/webpage/db_editor/project.py | 7 +- odmf/webpage/filemanager/file_auth.py | 157 ++++++++++++++++++++------ odmf/webpage/filemanager/upload.py | 153 +++++++++++++++---------- odmf/webpage/lib/render_tools.py | 2 +- odmf/webpage/static.py | 6 +- 11 files changed, 352 insertions(+), 186 deletions(-) diff --git a/odmf/db/person.py b/odmf/db/person.py index e5924477..de555e27 100644 --- a/odmf/db/person.py +++ b/odmf/db/person.py @@ -1,3 +1,5 @@ +import typing + import sqlalchemy as sql import sqlalchemy.orm as orm from functools import total_ordering @@ -51,6 +53,10 @@ def __lt__(self, other): return self.surname < other.surname def projects(self): + """ + Yields Project, access_level tuples + :return: + """ from .project import ProjectMember pm: ProjectMember for pm in ( diff --git a/odmf/db/project.py b/odmf/db/project.py index 1b6d12e7..75986d66 100644 --- a/odmf/db/project.py +++ b/odmf/db/project.py @@ -34,11 +34,11 @@ def members_query(self): """Returns a query object with all ProjectMember object related to this project""" return self.session().query(ProjectMember).filter(ProjectMember._project==self.id) def members(self, access_level=0): - for pm in ( - self.members_query.filter(ProjectMember.access_level>=access_level) - .order_by(ProjectMember.access_level.desc(), ProjectMember._member) - ): - yield pm.member, pm.access_level + for pm in ( + self.members_query.filter(ProjectMember.access_level>=access_level) + .order_by(ProjectMember.access_level.desc(), ProjectMember._member) + ): + yield pm.member, pm.access_level def add_member(self, person: Person|str, access_level: int=0): if pm:=self[person]: diff --git a/odmf/static/templates/download.html b/odmf/static/templates/download.html index 4b2cfdce..f9caad45 100644 --- a/odmf/static/templates/download.html +++ b/odmf/static/templates/download.html @@ -126,10 +126,11 @@
content
.. -
@@ -149,7 +150,6 @@
content
- -
+
-
-
- / + + / +
-
- +
+ + + + +
-
-
+

@@ -304,6 +312,8 @@

+
+
-
+ -
+
diff --git a/odmf/static/templates/project.html b/odmf/static/templates/project.html index c85168de..1dddd7e2 100644 --- a/odmf/static/templates/project.html +++ b/odmf/static/templates/project.html @@ -14,7 +14,7 @@
- + new project @@ -85,7 +85,7 @@

@@ -136,33 +136,34 @@

Members

  • - () + +
    - + + + > + +
- add member... -
+ add member... +
diff --git a/odmf/tools/__init__.py b/odmf/tools/__init__.py index 96692766..8bd30c03 100644 --- a/odmf/tools/__init__.py +++ b/odmf/tools/__init__.py @@ -11,10 +11,14 @@ class Path(object): - def __init__(self, *path: str, absolute=False): + def __init__(self, *path: str|Path|pathlib.Path, absolute=False): self.datapath = op.realpath(conf.datafiles) if path: - if str(path[0]).startswith('/') or absolute: + if type(path[0]) is pathlib.Path: + self.absolute = str(path[0].absolute()) + elif type(path[0]) is Path: + self.absolute = path[0].absolute + elif str(path[0]).startswith('/') or absolute: self.absolute = op.realpath(op.join(*path)) else: self.absolute = op.realpath(op.join(self.datapath, *path)) @@ -23,6 +27,9 @@ def __init__(self, *path: str, absolute=False): self.absolute = self.datapath self.name = '/' + def __hash__(self): + return hash(self.datapath) + @property def basename(self)->str: return op.basename(self.absolute) @@ -48,6 +55,9 @@ def __bool__(self): def __str__(self): return self.name + def __repr__(self): + return f"odmf.tools.Path('{self.name}')" + def formatsize(self)->str: size = op.getsize(self.absolute) unit = 0 @@ -76,10 +86,13 @@ def __gt__(self, other): def __add__(self, fn): return Path(op.join(self.absolute, fn)) + def __truediv__(self, fn): + return Path(op.join(self.absolute, fn)) + def make(self): os.makedirs(self.absolute, mode=0o770) - def breadcrumbs(self) -> list[str]: + def breadcrumbs(self) -> list[Path]: res = [self] p = op.dirname(self.absolute) while self.datapath in p: @@ -108,7 +121,7 @@ def parent(self) -> Path: def ishidden(self): return self.basename.startswith('.') or self.basename == 'index.html' - def listdir(self) -> (typing.List[Path], typing.List[Path]): + def listdir(self, hidden=False) -> (typing.List[Path], typing.List[Path]): """ Lists all members of the path in 2 lists: @@ -120,17 +133,22 @@ def listdir(self) -> (typing.List[Path], typing.List[Path]): files = [] directories = [] if self.isdir() and self.islegal(): - for fn in os.listdir(self.absolute): - if not fn.startswith('.'): - child = self.child(fn) - if child.isdir(): - directories.append(child) - elif child.isfile(): - files.append(child) + for child in self.iterdir(hidden): + if child.isdir(): + directories.append(child) + elif child.isfile(): + files.append(child) return directories, files else: return [], [] + def iterdir(self, hidden=False) -> typing.Generator[Path]: + if self.isdir() and self.islegal(): + for fn in os.listdir(self.absolute): + if hidden or not fn.startswith('.'): + yield self.child(fn) + + def isempty(self) -> bool: """ Returns True, if self isdir and has no entries diff --git a/odmf/webpage/auth.py b/odmf/webpage/auth.py index 60d702bf..467ee5a7 100644 --- a/odmf/webpage/auth.py +++ b/odmf/webpage/auth.py @@ -12,6 +12,7 @@ import cherrypy from ..tools import hashpw, get_bcrypt_salt +from enum import IntEnum ACCESS_LEVELS = [["Guest", "0"], ["Logger", "1"], @@ -94,31 +95,35 @@ class group: supervisor = 'supervisor' admin = 'admin' +class Level(IntEnum): + guest = 0 + logger = 1 + editor = 2 + supervisor = 3 + admin = 4 class User(object): - groups = ('guest', 'logger', 'editor', 'supervisor', 'admin') - - @classmethod - def __level_to_group(cls, level): - if level >= 0 and level < len(User.groups): - return User.groups[level] - else: - return "Unknown level %i" % level def __init__(self, name, level, password, projects=None): self.name = name - self.level = int(level) + self.level = Level(level) self.password = password self.person = None self.projects = projects or [] @property - def group(self): - return User.__level_to_group(self.level) + def group(self) -> str: + return self.level.name - def is_member(self, group): - grouplevel = User.groups.index(group) - return self.level >= grouplevel + def is_member(self, level: Level|str|int, project:int=None): + if type(level) is str: + level=Level[level] + else: + level = Level(level or 0) + if project is None: + return self.level >= level + else: + return project in self.projects and self.projects[project] >= level or self.level>=Level.admin def check(self, password): @@ -154,7 +159,10 @@ def load(self): q = session.query(db.Person).filter(db.Person.active == True) self.data = { - person.username: User(person.username, person.access_level, person.password) + person.username: User( + person.username, person.access_level, person.password, + projects = {project.id: Level(l) for project, l in person.projects()} + ) for person in q } @@ -208,10 +216,13 @@ def logout(self): -def is_member(group): - return bool(users.current) and users.current.is_member(group) +def is_member(level: Level|str, project=None): + return bool(users.current) and users.current.is_member(level, project) +def is_project_member(project: int, min_level:Level=Level.guest): + return bool(users.current) and users.current.projects.get(project, -1) >= min_level + def require(*conditions): """A decorator that appends conditions to the auth.require config variable.""" @@ -226,17 +237,17 @@ def decorate(f): return decorate -def member_of(groupname): +def member_of(level: Level|str|int, project: int = None): def check(): user = users.current - return user and user.is_member(groupname) + return user and user.is_member(level, project) return check def has_level(level): def check(): user = users.current - return user and user.level >= int(level) + return user and user.level >= Level(level) return check @@ -244,7 +255,10 @@ def expose_for(groupname=None): def decorate(f): if not hasattr(f, '_cp_config'): f._cp_config = {} - if groupname: + if type(groupname) is Level: + f._cp_config.setdefault('auth.require', []).append( + has_level(groupname)) + else: f._cp_config.setdefault('auth.require', []).append( member_of(groupname)) f.exposed = True diff --git a/odmf/webpage/db_editor/project.py b/odmf/webpage/db_editor/project.py index 5b7575ab..4ff84477 100644 --- a/odmf/webpage/db_editor/project.py +++ b/odmf/webpage/db_editor/project.py @@ -1,7 +1,7 @@ import cherrypy from .. import lib as web -from ..auth import group, expose_for +from ..auth import group, expose_for, users, Level from ... import db from ...config import conf @@ -10,7 +10,7 @@ @web.show_in_nav_for(1, 'user-friends') class ProjectPage: - @expose_for(group.logger) + @expose_for(Level.logger) def index(self, project_id=None, error=None, msg=None): with db.session_scope() as session: @@ -57,6 +57,7 @@ def save(self, project_id:str, name:str, person:str, comment: str, sourcelink: s raise RuntimeError('Spokesperson not found') except RuntimeError as e: error = f'Save failed: {e}' + users.load() raise web.redirect(f'/{conf.root_url}project/{project_id}', error=error, msg=f'{name} updated' if not error else None) @@ -70,6 +71,7 @@ def add_member(self, project_id, member_name, access_level): project.add_member(member_name, int(access_level)) except RuntimeError as e: error = f'Save failed: {e}' + users.load() raise web.redirect(f'/{conf.root_url}project/{project_id}', error=error, msg=f'{member_name} added' if not error else None) @expose_for(group.supervisor) @@ -82,6 +84,7 @@ def remove_member(self, project_id, member_name): project.remove_member(member_name) except RuntimeError as e: error = f'Save failed: {e}' + users.load() raise web.redirect(f'/{conf.root_url}project/{project_id}', error=error, msg=f'{member_name} removed' if not error else None) diff --git a/odmf/webpage/filemanager/file_auth.py b/odmf/webpage/filemanager/file_auth.py index 35b7dadb..40f89eea 100644 --- a/odmf/webpage/filemanager/file_auth.py +++ b/odmf/webpage/filemanager/file_auth.py @@ -1,44 +1,131 @@ from .. import auth -from pathlib import Path +from ...tools import Path +import yaml +import dataclasses +import typing +from enum import IntFlag +filename = '.access.yml' +owner_file = '.owner' +class Mode(IntFlag): + """ + This flag shows what can be done with a file. Read only, read/write or admin rights + """ + none = 0 + read = 1 + write = 3 + admin = 7 +@dataclasses.dataclass +class AccessRule: + """ + Access files are hidden files to steer the access to a directory and its children. They are in yaml format + and contain a rule object. The rule properties are: + - owner: str the user name of the owner of this directory (admin access) + - write: int default 9, access level needed for write access + - read: bool default write, access level needed for read access + - projects: list[int] list of projects this rule relates to. If empty the global + access level of the user is taken + The site admin (user.level=4), the project admins and the directory owner have admin access (may delete files) -class AccessRule: - def __init__(self, group='', project='', user=''): - self.group = group.strip() - self.user = user.strip() - self.project = project.strip() - - def __call__(self, user: auth.User): - return all([ - not self.group or user.is_member(self.group), - not self.user or user.name == self.user, - not self.project or int(self.project) in user.projects - ]) - - def __repr__(self): - return f'AccessRule(group={self.group}, project={self.project}, user={self.user})' - - -class AccessFile: - - def __init__(self, dir: Path): - self.path = dir / '.odmf.access' - self.rules = [] - if self.path.exists(): - with self.path.open() as f: - for line in f: - if not line.strip().startswith('#'): - self.rules.append(AccessRule(*line.split(','))) - - def check(self, user: auth.User): - if not self.rules: - return True + """ + + write: Mode = 0 + read: Mode = 0 + projects: list = dataclasses.field(default_factory=list) + + + def __call__(self, user: auth.User, owner:str = None) -> Mode: + """ + Returns if a user has admin access 7, read/write access 3, read only access 1 or 0 for no access to this ressource + """ + if user.level>=4 or user.name==owner: + return Mode.admin + + if self.projects: + max_level = max([auth.Level.guest] + [user.projects[p] for p in self.projects if p in user.projects]) + else: + max_level = user.level + if max_level >= auth.Level.admin: + return Mode.admin + elif max_level >= self.write: + return Mode.write + elif max_level >= self.read: + return Mode.read + else: + return Mode.none + + def save(self, path: Path): + with open((path / filename).absolute, 'w') as f: + yaml.safe_dump(self.__dict__, f) + + @classmethod + def from_file(cls, path: Path): + with open(path.absolute) as f: + content = yaml.safe_load(f) + return AccessRule(**content) + + @classmethod + def find_rule(cls, path: Path): + for bc in reversed(path.breadcrumbs()): + if (yml := bc / filename).exists(): + return AccessRule.from_file(yml) + else: + return AccessRule() + +def get_owner(path: Path) -> str|None: + """ + Loads the owner of a directory. Returns the owner's user name or None if no owner present + """ + path = Path(path) + for bc in reversed(path.breadcrumbs()): + if (fn := bc / owner_file).exists(): + with open(fn.absolute) as f: + return f.read().strip() + else: + return None + +def set_owner(path: Path, owner: str): + with open((path / owner_file).absolute, 'w') as f: + f.write(owner) + + + + +def check_directory(path: Path, user: auth.User) -> Mode: + """ + Checks if a .access.yml file is in the directory path and applies the given user to the rules. If no .access.yml + file is present, the function looks into the parent directories and uses their access rule. + :param path: + :param user: + :return: 7: Admin access, + """ + path = Path(path) + owner = get_owner(path) + rule = AccessRule.find_rule(path) + return rule(user, owner) + +def check_children(path: Path, user: auth.User) -> typing.Dict[Path, Mode]: + """ + Checks the path like check_directory and all children directories for existing access rules. If rules are present, + the rules of the children are returned, else the rule of the parent + :param path: A path to a directory + :param user: A auth.User + :return: A dict[Path: int] containing for each path in [path, path/*] the accessibility value [7,3,1,0] + """ + def check_child(p: Path): + if (yml:=p / filename).exists(): + owner = get_owner(p) + return p, AccessRule.from_file(yml)(user, owner) else: - return any(r(user) for r in self.rules) + return p, parent_mode + path = Path(path) + parent_mode = check_directory(path, user) + return {path: parent_mode} | dict( + check_child(f) + for f in path.iterdir() + ) - def __repr__(self): - return f'AccessFile(dir={self.path.parent})' diff --git a/odmf/webpage/filemanager/upload.py b/odmf/webpage/filemanager/upload.py index 77f3b27a..67c849d2 100644 --- a/odmf/webpage/filemanager/upload.py +++ b/odmf/webpage/filemanager/upload.py @@ -15,13 +15,14 @@ import cherrypy from cherrypy.lib.static import serve_file from urllib.parse import urlencode -from ..auth import group, expose_for, is_member +from ..auth import Level, expose_for, is_member, users from ...tools import Path from ...config import conf from .dbimport import DbImportPage from . import filehandlers as fh +from . import file_auth as fa def write_to_file(dest, src): @@ -49,13 +50,15 @@ def __init__(self, path: Path, status:int , message: str): def get_error_page(self, *args, **kwargs): error = f'Problem with {self.path}: {self.message}' + modes = fa.check_children(self.path, users.current) text = web.render( 'download.html', - error=error, message='', + error=error, message='', modes=modes, Mode=fa.Mode, files=[], directories=[], curdir=self.path, + content='', max_size=conf.upload_max_size ).render() @@ -72,7 +75,6 @@ def goto(dir, error=None, msg=None): - @web.show_in_nav_for(0, 'file') class DownloadPage(object): """The file management system. Used to upload, import and find files""" @@ -85,55 +87,66 @@ def _cp_dispatch(self, vpath: list): to_db = DbImportPage() filehandler = fh.MultiHandler() - @expose_for(group.logger) + + def render_file(self, path, error=None): + content = '' + try: + content = self.filehandler(path) + except ValueError as e: + content = f'

{e}

' + + except cherrypy.CherryPyException: + raise + + except Exception as e: + if error: error += '\n\n' + error += str(e) + if is_member(Level.admin): + error += '\n```\n' + traceback() + '\n```\n' + return content, error + + + @expose_for(Level.logger) @web.method.get def index(self, uri='.', error='', msg='', serve=False, _=None): path = Path(uri) - f_acc = AccessFile(path.to_pythonpath()) - if not f_acc.check(users.current): - raise web.HTTPError(403, f'Forbidden access to resource {path} for {users.current.name}') - directories, files = path.listdir() - content = '' - if path.isfile(): - if (not serve): - try: - content = self.filehandler(path) - except ValueError as e: - content = f'

{e}

' + modes = fa.check_children(path, users.current) - except cherrypy.CherryPyException: - raise + if not all(( + path.islegal(), + path.exists(), + )): + raise HTTPFileNotFoundError(path) + hidden = (path.ishidden() and modes[path]=fa.Mode.admin) - except Exception as e: - if error : error += '\n\n' - error += str(e) - if is_member(group.admin): - error += '\n```\n' + traceback() + '\n```\n' - - return web.render( - 'download.html', error=error, message=msg, - content=content, handler=self.filehandler, - files=sorted(files), - directories=sorted(directories), - curdir=path, - max_size=conf.upload_max_size - ).render() - else: + if path.isfile(): + if serve: return serve_file(path.absolute, disposition='attachment', name=path.basename) + else: + content, error = self.render_file(path, error) - elif not (path.islegal() and path.exists()): - raise HTTPFileNotFoundError(path) - else: - return web.render( - 'download.html', error=error, message=msg, - files=sorted(files), - directories=sorted(directories), - handler = self.filehandler, - curdir=path, - max_size=conf.upload_max_size - ).render() - - @expose_for(group.editor) + return web.render( + 'download.html', + error=error, message=msg, + modes=modes, Mode=fa.Mode, owner=fa.get_owner(path), + content = content, + files=sorted(files), + directories=sorted(directories), + handler=self.filehandler, + curdir=path, + max_size=conf.upload_max_size + ).render() + + + @expose_for(Level.editor) @web.method.post_or_put def upload(self, dir, datafiles, **kwargs): """ @@ -174,7 +187,7 @@ def upload(self, dir, datafiles, **kwargs): raise goto(dir, '\n'.join(error), '\n'.join(msg)) - @expose_for(group.logger) + @expose_for(Level.logger) @web.method.post def saveindex(self, dir, s): """Saves the string s to index.html @@ -184,14 +197,14 @@ def saveindex(self, dir, s): open(path.absolute, 'w').write(s) return web.markdown(s) - @expose_for(group.logger) + @expose_for(Level.logger) @web.method.get def getindex(self, dir): - index = Path(dir, 'index.html') io = StringIO() - if index.exists(): - text = open(index.absolute).read() - io.write(text) + for indexfile in ['README.md', 'index.html']: + if (index:=Path(dir, indexfile)).exists(): + text = open(index.absolute).read() + io.write(text) imphist = Path(dir, '.import.hist') @@ -202,7 +215,7 @@ def getindex(self, dir): io.write(f' * file:{dir}/{fn} imported by user:{user} at {date} into {ds}\n') return web.markdown(io.getvalue()) - @expose_for(group.logger) + @expose_for(Level.logger) @web.method.get @web.mime.json def listdir(self, dir, pattern=None): @@ -219,7 +232,7 @@ def listdir(self, dir, pattern=None): } ).encode('utf-8') - @expose_for(group.editor) + @expose_for(Level.editor) @web.method.post_or_put def newfolder(self, dir, newfolder): error = '' @@ -232,6 +245,7 @@ def newfolder(self, dir, newfolder): path = Path(dir, newfolder) if not path.exists() and path.islegal(): path.make() + fa.set_owner(path, users.current.name) msg = f"{path.href} created" else: error = f"Folder {newfolder} exists already" @@ -245,12 +259,13 @@ def newfolder(self, dir, newfolder): else: raise goto(dir, error, msg) - @expose_for(group.editor) + @expose_for(Level.editor) @web.method.post_or_put def newtextfile(self, dir, newfilename): if newfilename: try: path = Path(dir, newfilename) + if not path.basename.endswith('.wiki') or path.basename.endswith('.md'): path = Path(str(path) + '.wiki') if not path.exists() and path.islegal(): @@ -267,7 +282,7 @@ def newtextfile(self, dir, newfilename): else: raise goto(dir, error='Forgotten to give your new file a name?') - @expose_for(group.admin) + @expose_for(Level.editor) @web.method.post_or_delete def removefile(self, dir, filename): """ @@ -275,7 +290,9 @@ def removefile(self, dir, filename): """ path = Path(dir, filename) error = msg = '' - + mode = fa.check_directory(path, users.current) + if mode < fa.Mode.admin: + raise DownloadPageError(path, 403, 'You need to have admin rights on this directory') if path.isfile(): try: os.remove(path.absolute) @@ -286,7 +303,7 @@ def removefile(self, dir, filename): elif path.isdir(): if path.isempty(): try: - dirs, files = path.listdir() + dirs, files = path.listdir(hidden=True) for f in files: if f.ishidden(): os.remove(f.absolute) @@ -303,7 +320,7 @@ def removefile(self, dir, filename): url = f'{conf.root_url}/download/{dir}'.strip('.') return url + '?' + qs - @expose_for(group.editor) + @expose_for(Level.editor) @web.method.post def copyfile(self, dir, filename, newfilename): """ @@ -331,9 +348,21 @@ def copyfile(self, dir, filename, newfilename): url = f'{conf.root_url}/download/{dir}'.strip('.') return url + '?' + qs + @expose_for(Level.editor) + @web.method.post + def create_access_file(self, uri): + path = Path(uri) + rule = fa.AccessRule.find_rule(path) + owner = fa.get_owner(path) + if rule(users.current, owner)>=fa.Mode.admin: + rule.save(path) + raise goto((path / fa.filename)) + else: + raise DownloadPageError(path, status=403, message='Only admins can create access files') + - @expose_for(group.editor) + @expose_for(Level.editor) @web.method.post def write_to_file(self, path, text): path = Path(path) @@ -354,7 +383,7 @@ def write_to_file(self, path, text): @expose_for() @web.method.post def action(self, path, actionid: int): - from ..auth import users, User + from ..auth import users, User, Level path = Path(path) action_id = web.conv(int, actionid) handler = self.filehandler[path] @@ -363,8 +392,8 @@ def action(self, path, actionid: int): except IndexError: raise DownloadPageError('not enough actions available') if users.current.level < action.access_level: - required_group = User.groups[action.access_level] - raise DownloadPageError(path, 403,f'you need to be {required_group} for {action}') + required_group = Level(action.access_level) + raise DownloadPageError(path, 403,f'you need to be {required_group.name} for {action}') newpath = action(path) msg = {'msg': f'{action} on {path} successful'} return f'{newpath.href}?{urlencode(msg)}' diff --git a/odmf/webpage/lib/render_tools.py b/odmf/webpage/lib/render_tools.py index 6b6e98d9..2c1c1114 100644 --- a/odmf/webpage/lib/render_tools.py +++ b/odmf/webpage/lib/render_tools.py @@ -16,7 +16,7 @@ def markdown(s): return literal(__md(s)) # The imports are needed implicitly during rendering -from ..auth import users, is_member, has_level +from ..auth import users, is_member, has_level, Level from datetime import datetime, timedelta diff --git a/odmf/webpage/static.py b/odmf/webpage/static.py index 9994ebb7..8129cf6b 100644 --- a/odmf/webpage/static.py +++ b/odmf/webpage/static.py @@ -10,8 +10,7 @@ from ..config import conf from pathlib import Path from markdown import markdown -from .filemanager.file_auth import AccessFile - +from .filemanager.file_auth import check_directory def filelist2html(files): text = '\n'.join(f'- [{f.name}]({f.name})' for f in files) @@ -82,8 +81,7 @@ def index(self, path='.', _=None): Serves the static content from the relative path """ p = self.get_path(path) - f_acc = AccessFile(p) - if not f_acc.check(users.current): + if not check_directory(p, users.current): raise web.HTTPError(403, f'Forbidden access to resource {p} for {users.current.name}') if p.is_file():