SignalMesh / editor /scope.py
acecalisto3's picture
editor: records live in the site's own database, not an erasable file
9fdabdc verified
Raw History Blame Contribute Delete
3.81 kB
"""An optional narrowing of what a signed-in account may touch.
Joomla is the authority on permissions and stays that way — `joomla.permits`
walks the real asset line and nothing here can widen its answer. This only
subtracts, and exists because of a practical gap: most Joomla sites leave every
article inheriting the root rules, so "this person may edit exactly one page"
is not expressible in Joomla without hand-configuring per-article permissions.
An account with no rows here is unrestricted, which keeps the overlay opt-in —
adding the table changes nothing until someone deliberately narrows an account.
Owner and Super User scope bypasses this by design: it is a delegation tool,
not a way to fence in the person who owns the site.
Rows live in the site's own database (see `store`), so a fence survives a
restart. A fence that quietly disappeared would silently re-widen an account,
which is the worst direction for this particular mistake to fail in.
"""
import time
import store
COLUMNS = """
id INT AUTO_INCREMENT PRIMARY KEY,
ts DOUBLE NOT NULL,
user_id INT NOT NULL,
kind VARCHAR(16) NOT NULL,
ref INT NOT NULL,
UNIQUE KEY kyklos_scope_once (user_id, kind, ref)
"""
INDEXES = ("kyklos_scope_by_user (user_id)",)
KINDS = ("article", "category")
def _t(site=None):
return store.ensure("scope", COLUMNS, INDEXES, site)
def limits(site, user_id):
"""What this account is fenced to, or None when it is not fenced at all.
None and an empty fence are deliberately different: None means nobody has
narrowed this account, empty would mean somebody narrowed it to nothing.
"""
rows = store.query(f"SELECT kind, ref FROM {_t(site)} WHERE user_id = %s",
(int(user_id),), site)
if not rows:
return None
return {"articles": {r["ref"] for r in rows if r["kind"] == "article"},
"categories": {r["ref"] for r in rows if r["kind"] == "category"}}
def allows(site, user_id, article_id, category_id=None):
"""Is this article inside the fence? Unfenced accounts are always allowed."""
fence = limits(site, user_id)
if fence is None:
return True
if int(article_id) in fence["articles"]:
return True
return category_id is not None and int(category_id) in fence["categories"]
def grant(site, user_id, kind, ref):
"""Add one page or one category to an account's fence.
The first grant is what creates the fence, so this both narrows and
restricts: an unfenced account becomes fenced to exactly this.
"""
if kind not in KINDS:
raise ValueError(f"scope kind must be article or category, not {kind!r}")
store.write(
f"INSERT IGNORE INTO {_t(site)} (ts,user_id,kind,ref) VALUES (%s,%s,%s,%s)",
(time.time(), int(user_id), kind, int(ref)), site)
def revoke(site, user_id, kind=None, ref=None):
"""Drop one entry, or the whole fence when called with neither.
Dropping the last entry returns the account to unfenced — Joomla's answer
alone — rather than to a fence containing nothing, which would silently
lock the person out.
"""
q, args = f"DELETE FROM {_t(site)} WHERE user_id = %s", [int(user_id)]
if kind is not None:
q += " AND kind = %s"; args.append(kind)
if ref is not None:
q += " AND ref = %s"; args.append(int(ref))
_, dropped = store.write(q, tuple(args), site)
return dropped
def fenced(site):
"""Every narrowed account on one site, for the owner's view of who is."""
rows = store.query(
f"SELECT user_id, kind, ref FROM {_t(site)} ORDER BY user_id", (), site)
out = {}
for r in rows:
out.setdefault(r["user_id"], []).append({"kind": r["kind"], "ref": r["ref"]})
return out