diff --git a/continuousprint/api.py b/continuousprint/api.py index 9395388a..49a2a30f 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -4,12 +4,30 @@ from octoprint.server.util.flask import restricted_access import flask import json +from collections import namedtuple from .storage import queries -from .storage.database import DEFAULT_QUEUE +from .storage.database import DEFAULT_QUEUE, Queue, Set, Job from .driver import Action as DA from abc import ABC, abstractmethod +Creds = namedtuple("Credentials", ["user", "groups", "admin"]) + + +def get_creds(): + # https://flask-login.readthedocs.io/en/latest/#your-user-class + # https://github.com/OctoPrint/OctoPrint/blob/f430257d7072a83692fc2392c683ed8c97ae47b6/src/octoprint/server/api/access.py#L171 + from flask_login import current_user + from octoprint.server import userManager + + usr = userManager.find_user(current_user.get_name()) + return Creds( + usr.get_id(), + [g.get_name() for g in usr.groups], + Permissions.PLUGIN_CONTINUOUSPRINT_QUEUE_ADMIN.can(), + ) + + class Permission(Enum): STARTSTOP = ( "Start and Stop Queue", @@ -41,6 +59,11 @@ class Permission(Enum): "Allows for adding/removing queues and rearranging them", True, ) + QUEUE_ADMIN = ( + "Queue administrator", + "Skip user/group permissions checks when performing operations", + True, + ) def __init__(self, longname, desc, dangerous): self.longname = longname @@ -58,12 +81,12 @@ def as_dict(self): ) -def cpq_permission(perm: Permission): +def cpq_permission(perm: Permission, field=None): def cpq_permission_decorator(func): def cpq_permission_wrapper(*args, **kwargs): if not getattr(Permissions, f"PLUGIN_CONTINUOUSPRINT_{perm.name}").can(): return flask.make_response(f"Insufficient Rights ({perm.name})", 403) - return func(*args, **kwargs) + return func(*args, **kwargs, creds=get_creds()) # the BlueprintPlugin decorator used below relies on the original function name # to map the function to an HTTP handler @@ -141,7 +164,7 @@ def get_state(self): @octoprint.plugin.BlueprintPlugin.route("/set_active", methods=["POST"]) @restricted_access @cpq_permission(Permission.STARTSTOP) - def set_active(self): + def set_active(self, creds): self._update( DA.ACTIVATE if flask.request.form["active"] == "true" else DA.DEACTIVATE ) @@ -153,17 +176,17 @@ def set_active(self): @octoprint.plugin.BlueprintPlugin.route("/set/add", methods=["POST"]) @restricted_access @cpq_permission(Permission.ADDSET) - def add_set(self): + def add_set(self, creds): data = self._preprocess_set(dict(**flask.request.form)) return json.dumps( - self._get_queue(DEFAULT_QUEUE).add_set(data.get("job", ""), data) + self._get_queue(DEFAULT_QUEUE).add_set(data.get("job", ""), data, creds) ) # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/add", methods=["POST"]) @restricted_access @cpq_permission(Permission.ADDJOB) - def add_job(self): + def add_job(self, creds): return json.dumps( self._get_queue(DEFAULT_QUEUE) .add_job(flask.request.form.get("name")) @@ -174,7 +197,7 @@ def add_job(self): @octoprint.plugin.BlueprintPlugin.route("/set/mv", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def mv_set(self): + def mv_set(self, creds): self._get_queue(DEFAULT_QUEUE).mv_set( int(flask.request.form["id"]), int( @@ -190,7 +213,7 @@ def mv_set(self): @octoprint.plugin.BlueprintPlugin.route("/job/mv", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def mv_job(self): + def mv_job(self, creds): self._get_queue(DEFAULT_QUEUE).mv_job( int(flask.request.form["id"]), int( @@ -203,7 +226,7 @@ def mv_job(self): @octoprint.plugin.BlueprintPlugin.route("/job/submit", methods=["POST"]) @restricted_access @cpq_permission(Permission.ADDJOB) - def submit_job(self): + def submit_job(self, creds): j = queries.getJob(int(flask.request.form["id"])) # Submit to the queue and remove from its origin err = self._get_queue(flask.request.form["queue"]).submit_job(j) @@ -219,15 +242,17 @@ def submit_job(self): @octoprint.plugin.BlueprintPlugin.route("/job/edit", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def edit_job(self): + def edit_job(self, creds): data = json.loads(flask.request.form.get("json")) - return json.dumps(self._get_queue(DEFAULT_QUEUE).edit_job(data["id"], data)) + return json.dumps( + self._get_queue(DEFAULT_QUEUE).edit_job(data["id"], data, creds) + ) # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/import", methods=["POST"]) @restricted_access @cpq_permission(Permission.ADDJOB) - def import_job(self): + def import_job(self, creds): return json.dumps( self._get_queue(flask.request.form["queue"]) .import_job(flask.request.form["path"]) @@ -238,7 +263,7 @@ def import_job(self): @octoprint.plugin.BlueprintPlugin.route("/job/export", methods=["POST"]) @restricted_access @cpq_permission(Permission.EXPORTJOB) - def export_job(self): + def export_job(self, creds): job_ids = [int(jid) for jid in flask.request.form.getlist("job_ids[]")] results = [] root_dir = self._path_on_disk("/") @@ -253,7 +278,7 @@ def export_job(self): @octoprint.plugin.BlueprintPlugin.route("/job/rm", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def rm_job(self): + def rm_job(self, creds): return json.dumps( self._get_queue(flask.request.form["queue"]).remove_jobs( flask.request.form.getlist("job_ids[]") @@ -264,7 +289,7 @@ def rm_job(self): @octoprint.plugin.BlueprintPlugin.route("/set/rm", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def rm_set(self): + def rm_set(self, creds): return json.dumps( self._get_queue(DEFAULT_QUEUE).rm_multi( set_ids=flask.request.form.getlist("set_ids[]") @@ -275,7 +300,7 @@ def rm_set(self): @octoprint.plugin.BlueprintPlugin.route("/job/reset", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITJOB) - def reset_multi(self): + def reset_multi(self, creds): return json.dumps( self._get_queue(flask.request.form["queue"]).reset_jobs( flask.request.form.getlist("job_ids[]") @@ -286,14 +311,14 @@ def reset_multi(self): @octoprint.plugin.BlueprintPlugin.route("/history/get", methods=["GET"]) @restricted_access @cpq_permission(Permission.GETHISTORY) - def get_history(self): + def get_history(self, creds): return self._history_json() # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/history/reset", methods=["POST"]) @restricted_access @cpq_permission(Permission.CLEARHISTORY) - def reset_history(self): + def reset_history(self, creds): queries.resetHistory() return json.dumps("OK") @@ -301,14 +326,14 @@ def reset_history(self): @octoprint.plugin.BlueprintPlugin.route("/queues/get", methods=["GET"]) @restricted_access @cpq_permission(Permission.GETQUEUES) - def get_queues(self): + def get_queues(self, creds): return json.dumps([q.as_dict() for q in queries.getQueues()]) # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/queues/edit", methods=["POST"]) @restricted_access @cpq_permission(Permission.EDITQUEUES) - def edit_queues(self): + def edit_queues(self, creds): queues = json.loads(flask.request.form.get("json")) (absent_names, added) = queries.assignQueues(queues) self._commit_queues(added, absent_names) diff --git a/continuousprint/driver.py b/continuousprint/driver.py index 25599b08..51867648 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -266,17 +266,22 @@ def _state_start_clearing(self, a: Action, p: Printer): return self._state_clearing def _state_cooldown(self, a: Action, p: Printer): + clear = False if self._bed_temp < self.cooldown_threshold: self._logger.info( f"Cooldown threshold of {self.cooldown_threshold} has been met" ) - return self._state_clearing + clear = True elif (time.time() - self.cooldown_start) > (60 * self.cooldown_timeout): self._logger.info(f"Timeout of {self.cooldown_timeout} minutes exceeded") - return self._state_clearing + clear = True else: self._set_status("Cooling down") + if clear: + self._runner.clear_bed() + return self._state_clearing + def _state_clearing(self, a: Action, p: Printer): if a == Action.SUCCESS: return self._enter_start_print(a, p) diff --git a/continuousprint/queues/local.py b/continuousprint/queues/local.py index cade52af..bda16d07 100644 --- a/continuousprint/queues/local.py +++ b/continuousprint/queues/local.py @@ -89,44 +89,44 @@ def as_dict(self) -> dict: ) ) - def remove_jobs(self, job_ids): - return self.rm_multi(job_ids=job_ids) + def remove_jobs(self, job_ids, creds): + return self.rm_multi(job_ids=job_ids, creds=creds) - def reset_jobs(self, job_ids): - return self.queries.resetJobs(job_ids) + def reset_jobs(self, job_ids, creds): + return self.queries.resetJobs(job_ids, creds=creds) # -------------- end AbstractQueue ------------------ # -------------- begin AbstractEditableQueue ----------- - def add_job(self, name="") -> JobView: - return self.queries.newEmptyJob(self.ns, name) + def add_job(self, creds, name="") -> JobView: + return self.queries.newEmptyJob(self.ns, name, creds=creds) - def add_set(self, job_id, data) -> SetView: - return self.queries.appendSet(self.ns, job_id, data) + def add_set(self, job_id, data, creds) -> SetView: + return self.queries.appendSet(self.ns, job_id, data, creds=creds) - def mv_set(self, set_id, after_id, dest_job): - return self.queries.moveSet(set_id, after_id, dest_job) + def mv_set(self, set_id, after_id, dest_job, creds): + return self.queries.moveSet(set_id, after_id, dest_job, creds=creds) - def mv_job(self, job_id, after_id): - return self.queries.moveJob(job_id, after_id) + def mv_job(self, job_id, after_id, creds): + return self.queries.moveJob(job_id, after_id, creds=creds) - def edit_job(self, job_id, data): - return self.queries.updateJob(job_id, data) + def edit_job(self, job_id, data, creds): + return self.queries.updateJob(job_id, data, creds=creds) - def rm_multi(self, job_ids=[], set_ids=[]) -> dict: - return self.queries.remove(job_ids=job_ids, set_ids=set_ids) + def rm_multi(self, creds, job_ids=[], set_ids=[]) -> dict: + return self.queries.remove(job_ids=job_ids, set_ids=set_ids, creds=creds) - def import_job(self, gjob_path: str, draft=True) -> dict: + def import_job(self, gjob_path: str, creds, draft=True) -> dict: out_dir = str(Path(gjob_path).stem) self._mkdir(out_dir) manifest, filepaths = unpack_job( self._path_on_disk(gjob_path), self._path_on_disk(out_dir) ) - return self.queries.importJob(self.ns, manifest, out_dir, draft) + return self.queries.importJob(self.ns, manifest, out_dir, draft, creds=creds) - def export_job(self, job_id: int, dest_dir: str) -> str: - j = self.queries.getJob(job_id) + def export_job(self, job_id: int, dest_dir: str, creds) -> str: + j = self.queries.getJob(job_id, creds=creds) filepaths = dict([(s.path, self._path_on_disk(s.path)) for s in j.sets]) with tempfile.NamedTemporaryFile( suffix=".gjob", dir=dest_dir, delete=False diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index b8b44d7d..03849606 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -25,6 +25,39 @@ import time +class PermView: + def _allowed(self, user, groups, action_mask): + action_mask = action_mask & 0x7 + return ( + (user == self.owner and (self.perms >> 6) & action_mask != 0) + or (self.group in groups and (self.perms >> 3) & action_mask != 0) + or (self.perms & action_mask != 0) + ) + + def permstr(self): + r = "".join( + reversed( + ["xwr"[i % 3] if (self.perms & (1 << i)) else "-" for i in range(9)] + ) + ) + return f"{r[0:3]} {r[3:6]} {r[6:]}" + + def can_read(self, user, groups): + return self._allowed(user, groups, 0x4) + + def can_write(self, user, groups): + return self._allowed(user, groups, 0x2) + + def can_exec(self, user, groups): + return self._allowed(user, groups, 0x1) + + +class PermModel(Model): + perms = IntegerField(default=0b111100100) + owner = CharField(default="admin") + group = CharField(null=True) + + # Defer initialization class DB: # Adding foreign_keys pragma is necessary for ON DELETE behavior @@ -43,7 +76,7 @@ class Meta: database = DB.queues -class Queue(Model): +class Queue(PermView, PermModel): name = CharField(unique=True) created = DateTimeField(default=datetime.datetime.now) rank = FloatField() @@ -62,7 +95,7 @@ def as_dict(self): return q -class JobView: +class JobView(PermView): """The job view contains functions used to manipulate an underlying Job model. This is distinct from the Job class to facilitate other storage implementations (e.g. LAN queue data)""" @@ -127,7 +160,7 @@ def as_dict(self): return d -class Job(Model, JobView): +class Job(PermModel, JobView): queue = ForeignKeyField(Queue, backref="jobs", on_delete="CASCADE") name = CharField() rank = FloatField() @@ -154,7 +187,7 @@ def refresh_sets(self): Set.update(remaining=Set.count).where(Set.job == self).execute() -class SetView: +class SetView(PermView): """See JobView for rationale for this class.""" def _csv2list(self, v): @@ -196,7 +229,7 @@ def as_dict(self): ) -class Set(Model, SetView): +class Set(PermModel, SetView): path = CharField() sd = BooleanField() job = ForeignKeyField(Job, backref="sets", on_delete="CASCADE") @@ -229,7 +262,7 @@ def from_dict(self, s): return Set(**s) -class Run(Model): +class Run(PermView, PermModel): # Runs are totally decoupled from queues, jobs, and sets - this ensures that # the run history persists even if the other items are deleted queueName = CharField() @@ -277,9 +310,11 @@ def file_exists(path: str) -> bool: def populate(): DB.queues.create_tables(MODELS) StorageDetails.create(schemaVersion="0.0.2") - Queue.create(name=LAN_QUEUE, addr="auto", strategy="LINEAR", rank=1) - Queue.create(name=DEFAULT_QUEUE, strategy="LINEAR", rank=0) - Queue.create(name=ARCHIVE_QUEUE, strategy="LINEAR", rank=-1) + Queue.create( + name=LAN_QUEUE, addr="auto", strategy="LINEAR", rank=1, mask=0b111111111 + ) + Queue.create(name=DEFAULT_QUEUE, strategy="LINEAR", rank=0, mask=0b111111111) + Queue.create(name=ARCHIVE_QUEUE, strategy="LINEAR", rank=-1, mask=0b111111111) def init(db_path="queues.sqlite3", logger=None): diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index b121309e..33b9b3df 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -4,6 +4,7 @@ from .database import ( migrateFromSettings, init as init_db, + PermView, Queue, Job, Set, @@ -12,7 +13,51 @@ ) import tempfile + # logging.basicConfig(level=logging.DEBUG) +class PermTest(unittest.TestCase): + def _p(self, perms, owner, group): + p = PermView() + p.perms = perms + p.owner = owner + p.group = group + return p + + def testParameterized(self): + U = "u" + G = "g" + X = "x" + for own, grp, perms, wantRead, wantWrite, wantExec in [ + (U, G, 0b000000000, False, False, False), # No perms + (U, G, 0b111111111, True, True, True), # All perms + (X, X, 0b000000100, True, False, False), # Any read + (X, G, 0b000100000, True, False, False), # Group read + (X, X, 0b000100000, False, False, False), # Group read, wrong user + (U, X, 0b100000000, True, False, False), # User read + (X, X, 0b100000000, False, False, False), # User read, wrong user + (X, X, 0b000000010, False, True, False), # Any write + (X, G, 0b000010000, False, True, False), # Group write + (X, X, 0b000010000, False, False, False), # Group write, wrong user + (U, X, 0b010000000, False, True, False), # User write + (X, X, 0b010000000, False, False, False), # User write, wrong user + (X, X, 0b000000001, False, False, True), # Any exec + (X, G, 0b000001000, False, False, True), # Group exec + (X, X, 0b000001000, False, False, False), # Group exec, wrong user + (U, X, 0b001000000, False, False, True), # User exec + (X, X, 0b001000000, False, False, False), # User exec, wrong user + ]: + p = self._p(perms, own, grp) + with self.subTest( + own=own, + grp=grp, + perms=p.permstr(), + wantRead=wantRead, + wantWrite=wantWrite, + wantExec=wantExec, + ): + self.assertEqual(p.can_read(U, [G]), wantRead) + self.assertEqual(p.can_write(U, [G]), wantWrite) + self.assertEqual(p.can_exec(U, [G]), wantExec) class DBTest(unittest.TestCase): diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index d40ffcb1..09ec0b8f 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -32,7 +32,7 @@ def releaseJob(j) -> bool: return True -def importJob(qname, manifest: dict, dirname: str, draft=False): +def importJob(qname, manifest: dict, dirname: str, creds, draft=False): q = Queue.get(name=qname) # Manifest may have "remaining" values set incorrectly for new job; ensure @@ -126,7 +126,7 @@ def getJobsAndSets(queue): ).execute() -def getJob(jid): +def getJob(jid, creds=None): return Job.get(id=jid) @@ -182,13 +182,20 @@ def _upsertSet(set_id, data, job): s.save() -def updateJob(job_id, data, queue=DEFAULT_QUEUE): - with DB.queues.atomic(): +def updateJob(job_id, data, creds=None, queue=DEFAULT_QUEUE): + print("updateJob", creds) + + with DB.queues.atomic() as txn: try: j = Job.get(id=job_id) except Job.DoesNotExist: q = Queue.get(name=queue) - j = newEmptyJob(q) + j = newEmptyJob(q, creds) + + if not creds.admin and not j.can_write(creds.user, creds.groups): + txn.rollback() + raise Exception("Cannot write to job with id {j.id}: permission denied") + for k, v in data.items(): if k in ( "id", @@ -291,12 +298,12 @@ def _moveImpl(cls, src, dest_id: int, retried=False): src.save() -def moveJob(src_id: int, dest_id: int): +def moveJob(src_id: int, dest_id: int, creds): j = Job.get(id=src_id) return _moveImpl(Job, j, dest_id) -def moveSet(src_id: int, dest_id: int, dest_job: int): +def moveSet(src_id: int, dest_id: int, dest_job: int, creds): s = Set.get(id=src_id) if dest_job == -1: j = newEmptyJob(s.job.queue) @@ -307,19 +314,21 @@ def moveSet(src_id: int, dest_id: int, dest_job: int): _moveImpl(Set, s, dest_id) -def newEmptyJob(q, name="", rank=_rankEnd): +def newEmptyJob(q, creds, name="", rank=_rankEnd): if type(q) == str: q = Queue.get(name=q) j = Job.create( queue=q, rank=rank(), name=name, + owner=creds.owner, + group=None, count=1, ) return j -def appendSet(queue: str, jid, data: dict, rank=_rankEnd): +def appendSet(queue: str, jid, data: dict, creds=None, rank=_rankEnd): q = Queue.get(name=queue) try: if jid != "": @@ -353,7 +362,7 @@ def appendSet(queue: str, jid, data: dict, rank=_rankEnd): return dict(job_id=j.id, set_=s.as_dict()) -def remove(queue_ids: list = [], job_ids: list = [], set_ids: list = []): +def remove(queue_ids: list = [], job_ids: list = [], set_ids: list = [], creds=None): result = {} with DB.queues.atomic(): if len(queue_ids) > 0: