WIP
This commit is contained in:
parent
582fd2d9a1
commit
6224d5fea4
|
@ -7,6 +7,7 @@ from dotenv import dotenv_values
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_session import Session
|
from flask_session import Session
|
||||||
from grung.db import GrungDB
|
from grung.db import GrungDB
|
||||||
|
from tinydb import where
|
||||||
from tinydb.storages import MemoryStorage
|
from tinydb.storages import MemoryStorage
|
||||||
|
|
||||||
from ttfrog import schema
|
from ttfrog import schema
|
||||||
|
@ -112,50 +113,27 @@ VIEW_URI=/
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
raise ApplicationNotInitializedError("This action requires the application to be initialized.")
|
raise ApplicationNotInitializedError("This action requires the application to be initialized.")
|
||||||
|
|
||||||
def add_member(self, parent: schema.Page, child: schema.Page):
|
def authenticate(self, username: str, password: str) -> schema.User:
|
||||||
parent.members.append(self.db.save(child))
|
|
||||||
parent = self.db.save(parent)
|
|
||||||
return parent.get_child(child)
|
|
||||||
|
|
||||||
def bootstrap(self):
|
|
||||||
"""
|
"""
|
||||||
Bootstrap the database entries by populating the first Page, the Admin user and the Admins group.
|
Returns the User record matching the given username and password
|
||||||
"""
|
"""
|
||||||
self.check_state()
|
if not (username and password):
|
||||||
|
self.web.logger.debug("Need both username and password to login")
|
||||||
|
return None
|
||||||
|
|
||||||
# create the top-level pages
|
user = self.db.User.get(where("name") == username)
|
||||||
root = self.db.save(schema.Page(name=self.config.VIEW_URI, title="Home", body="This is the home page"))
|
if not user:
|
||||||
users = self.add_member(root, schema.Page(name="User", title="Users", body="users go here."))
|
self.web.logger.debug(f"No user matching {username}")
|
||||||
groups = self.add_member(root, schema.Page(name="Group", title="Groups", body="groups go here."))
|
return None
|
||||||
npcs = self.add_member(root, schema.Page(name="NPC", title="NPCS!", body="NPCS!"))
|
|
||||||
|
|
||||||
# create the NPCs
|
if not user.check_credentials(username, password):
|
||||||
sabetha = self.db.save(schema.NPC(name="Sabetha", body=""))
|
self.web.logger.debug(f"Invalid credentials for {username}")
|
||||||
johns = self.db.save(schema.NPC(name="John", body=""))
|
return None
|
||||||
self.add_member(npcs, sabetha)
|
|
||||||
self.add_member(npcs, johns)
|
|
||||||
|
|
||||||
guest = self.add_member(users, schema.User(name="guest"))
|
return user
|
||||||
|
|
||||||
# create the admin user and admins group
|
def authorize(self, user, record, requested):
|
||||||
admin = self.add_member(
|
return user.has_permission(record, requested)
|
||||||
users, schema.User(name=self.config.ADMIN_USERNAME, password="fnord", email=self.config.ADMIN_EMAIL)
|
|
||||||
)
|
|
||||||
admins = self.db.save(schema.Group(name="administrators"))
|
|
||||||
admins = self.add_member(groups, admins)
|
|
||||||
admin = self.add_member(admins, admin)
|
|
||||||
|
|
||||||
groups.set_permissions(
|
|
||||||
admins,
|
|
||||||
permissions=[schema.Permissions.READ, schema.Permissions.WRITE, schema.Permissions.DELETE],
|
|
||||||
db=self.db,
|
|
||||||
)
|
|
||||||
|
|
||||||
users.set_permissions(
|
|
||||||
admins,
|
|
||||||
permissions=[schema.Permissions.READ, schema.Permissions.WRITE, schema.Permissions.DELETE],
|
|
||||||
db=self.db,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
sys.modules[__name__] = ApplicationContext()
|
sys.modules[__name__] = ApplicationContext()
|
||||||
|
|
|
@ -9,6 +9,7 @@ from rich import print
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
import ttfrog.app
|
import ttfrog.app
|
||||||
|
from ttfrog.bootstrap import bootstrap
|
||||||
|
|
||||||
main_app = typer.Typer()
|
main_app = typer.Typer()
|
||||||
|
|
||||||
|
@ -52,7 +53,7 @@ def init(context: typer.Context, drop: bool = typer.Option(False, help="Drop tab
|
||||||
ttfrog.app.db.drop_tables()
|
ttfrog.app.db.drop_tables()
|
||||||
ttfrog.app.db.close()
|
ttfrog.app.db.close()
|
||||||
ttfrog.app.initialize(force=True)
|
ttfrog.app.initialize(force=True)
|
||||||
ttfrog.app.bootstrap()
|
bootstrap()
|
||||||
print(ttfrog.app.db.Page.all())
|
print(ttfrog.app.db.Page.all())
|
||||||
print(ttfrog.app.db)
|
print(ttfrog.app.db)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import cache
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from grung.types import BackReference, Collection, DateTime, Field, Password, Pointer, Record, Timestamp
|
from grung.types import BackReference, Collection, DateTime, Field, Password, Pointer, Record, Timestamp
|
||||||
from tinydb import Query
|
from tinydb import where
|
||||||
|
|
||||||
|
|
||||||
class Page(Record):
|
class Page(Record):
|
||||||
|
@ -14,19 +15,21 @@ class Page(Record):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fields(cls):
|
def fields(cls):
|
||||||
|
# fmt: off
|
||||||
return [
|
return [
|
||||||
*super().fields(), # Pick up the UID and whatever other non-optional fields exist
|
*super().fields(), # Pick up the UID and whatever other non-optional fields exist
|
||||||
Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI
|
Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI
|
||||||
Field("name"), # The portion of the URI after the last /
|
Field("name"), # The portion of the URI after the last /
|
||||||
Field("title"), # The page title
|
Field("title"), # The page title
|
||||||
Field("body"), # The main content blob of the page
|
Field("body"), # The main content blob of the page
|
||||||
Collection("members", Page), # The pages that exist below this page's URI
|
Collection("members", Page), # The pages that exist below this page's URI
|
||||||
BackReference("parent", value_type=Page), # The page that exists above this page's URI
|
BackReference("parent", value_type=Page), # The page that exists above this page's URI
|
||||||
Pointer("author", value_type=User), # The last user to touch the page.
|
Pointer("author", value_type=User), # The last user to touch the page.
|
||||||
DateTime("created"), # When the page was created
|
DateTime("created"), # When the page was created
|
||||||
Timestamp("last_modified"), # The last time the page was modified.
|
Timestamp("last_modified"), # The last time the page was modified.
|
||||||
Collection("acl", Permissions),
|
Collection("acl", Permissions), # The access control list
|
||||||
]
|
]
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
def before_insert(self, db):
|
def before_insert(self, db):
|
||||||
"""
|
"""
|
||||||
|
@ -62,62 +65,90 @@ class Page(Record):
|
||||||
obj.uri = f"{self.uri}/{obj.name}"
|
obj.uri = f"{self.uri}/{obj.name}"
|
||||||
child = db.save(obj)
|
child = db.save(obj)
|
||||||
|
|
||||||
|
def add_member(self, child: Record):
|
||||||
|
from ttfrog import app
|
||||||
|
|
||||||
|
app.check_state()
|
||||||
|
self.members = list(set(self.members + [app.db.save(child)]))
|
||||||
|
app.db.save(self)
|
||||||
|
return self.get_child(child)
|
||||||
|
|
||||||
def get_child(self, obj: Record):
|
def get_child(self, obj: Record):
|
||||||
for page in self.members:
|
for page in self.members:
|
||||||
if page.uid == obj.uid:
|
if page.uid == obj.uid:
|
||||||
return page
|
return page
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_permissions(self, entity: Entity, permissions: List, db) -> Record:
|
def set_permissions(self, entity: Entity, permissions: List) -> Record:
|
||||||
perms = db.save(Permissions(entity=entity, grants="".join(permissions)))
|
from ttfrog import app
|
||||||
self.acl = [entry for entry in self.acl if entry.entity != entity] + [perms]
|
|
||||||
self = db.save(self)
|
app.check_state()
|
||||||
|
|
||||||
|
perms = app.db.save(Permissions(entity=entity, grants="".join(permissions)))
|
||||||
|
self.acl = list(set(self.acl + [perms]))
|
||||||
|
app.db.save(self)
|
||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_acl_for_entity(self, entity) -> list:
|
||||||
|
"""
|
||||||
|
Search upward through the page hierarchy looking for one with an ACL that either
|
||||||
|
has a grant for the entity we care about, or at least one group in which the entity is a member.
|
||||||
|
"""
|
||||||
|
from ttfrog import app
|
||||||
|
|
||||||
|
app.check_state()
|
||||||
|
|
||||||
|
def find_acl(obj):
|
||||||
|
if hasattr(obj, "acl"):
|
||||||
|
# examine each entry in the ACL and see if one refers to the entity we care about
|
||||||
|
group_grants = []
|
||||||
|
for entry in obj.acl:
|
||||||
|
if type(entry) == str:
|
||||||
|
entry = app.db.Permissions.get(where("uid") == entry.split("::")[1], recurse=False)
|
||||||
|
|
||||||
|
# grants specific to the entity always take precedence
|
||||||
|
if entry.entity.uid == entity.uid:
|
||||||
|
return [entry]
|
||||||
|
|
||||||
|
# keep track of grants to groups containing the entity
|
||||||
|
elif entity.reference in getattr(entry.entity, "members", []):
|
||||||
|
group_grants.append(entry)
|
||||||
|
|
||||||
|
# if we found group grants, return them
|
||||||
|
if group_grants:
|
||||||
|
return group_grants
|
||||||
|
|
||||||
|
# no ACL on this object, so check its parent, if there is one
|
||||||
|
if not hasattr(obj, "parent"):
|
||||||
|
return []
|
||||||
|
return find_acl(obj.parent)
|
||||||
|
|
||||||
|
return find_acl(self)
|
||||||
|
|
||||||
|
|
||||||
class Entity(Page):
|
class Entity(Page):
|
||||||
def has_permission(self, record: Record, requested: str, db) -> bool | None:
|
def has_permission(self, record: Record, requested: str) -> bool | None:
|
||||||
|
for acl in record.get_acl_for_entity(self):
|
||||||
# Find a non-empty ACL to use by starting with the requested reecord and traversing
|
if requested in acl.grants:
|
||||||
# the hierarchy upwards. If we get to the root and there's no ACL anywhere, default
|
|
||||||
# to READ permissions.
|
|
||||||
def find_acl(obj):
|
|
||||||
if hasattr(obj, 'acl') and obj.acl:
|
|
||||||
return obj.acl
|
|
||||||
if not hasattr(obj, "parent"):
|
|
||||||
return None
|
|
||||||
return find_acl(obj.parent)
|
|
||||||
|
|
||||||
acl = find_acl(record)
|
|
||||||
if not acl:
|
|
||||||
return requested == Permissions.READ
|
|
||||||
|
|
||||||
# Use the grant specific to this entity, if there is one
|
|
||||||
for entry in record.acl:
|
|
||||||
if entry.entity.uid == self.uid:
|
|
||||||
return requested in entry.grants
|
|
||||||
|
|
||||||
# Check for grants for each of the entity's groups, if any
|
|
||||||
for group in db.Group.search(Query()["members"].any([self.reference])):
|
|
||||||
if group.has_permission(record, requested, db):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_read(self, record: Record, db):
|
def can_read(self, record: Record):
|
||||||
return self.has_permission(record, Permissions.READ, db)
|
return self.has_permission(record, Permissions.READ)
|
||||||
|
|
||||||
def can_write(self, record: Record, db):
|
def can_write(self, record: Record):
|
||||||
return self.has_permission(record, Permissions.WRITE, db)
|
return self.has_permission(record, Permissions.WRITE)
|
||||||
|
|
||||||
def can_delete(self, record: Record, db):
|
def can_delete(self, record: Record):
|
||||||
return self.has_permission(record, Permissions.DELETE, db)
|
return self.has_permission(record, Permissions.DELETE)
|
||||||
|
|
||||||
|
|
||||||
class User(Entity):
|
class User(Entity):
|
||||||
"""
|
"""
|
||||||
A website user, editable as a wiki page.
|
A website user, editable as a wiki page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def check_credentials(self, username: str, password: str) -> bool:
|
def check_credentials(self, username: str, password: str) -> bool:
|
||||||
return username == self.name and self._metadata.fields["password"].compare(password, self.password)
|
return username == self.name and self._metadata.fields["password"].compare(password, self.password)
|
||||||
|
|
||||||
|
|
|
@ -33,22 +33,16 @@ def get_page(path: str = "", table: str = "Page", create_okay: bool = False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
page = app.db.table(table).get(where("uri") == uri, recurse=False)
|
page = app.db.table(table).get(where("uri") == uri, recurse=False)
|
||||||
if hasattr(page, "acl"):
|
|
||||||
acl = []
|
|
||||||
for pointer in page.acl:
|
|
||||||
table, uid = pointer.split("::")
|
|
||||||
acl += app.db.table(table).search(where("uid") == uid, recurse=False)
|
|
||||||
page.acl = acl
|
|
||||||
|
|
||||||
if not page:
|
if not page:
|
||||||
if not create_okay:
|
if not create_okay:
|
||||||
return None
|
return None
|
||||||
parent = get_parent(table, uri)
|
parent = get_parent(table, uri)
|
||||||
if not g.user.can_read(parent, app.db):
|
if not app.authorize(g.user, parent, schema.Permissions.READ):
|
||||||
return None
|
return None
|
||||||
return getattr(schema, table)(name=uri.split("/")[-1], body="This page does not exist", parent=parent)
|
return getattr(schema, table)(name=uri.split("/")[-1], body="This page does not exist", parent=parent)
|
||||||
|
|
||||||
if not g.user.can_read(page, app.db):
|
if not app.authorize(g.user, page, schema.Permissions.READ):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if hasattr(page, "members"):
|
if hasattr(page, "members"):
|
||||||
|
@ -89,37 +83,18 @@ def index():
|
||||||
return rendered(get_page(create_okay=False))
|
return rendered(get_page(create_okay=False))
|
||||||
|
|
||||||
|
|
||||||
def do_login(username: str, password: str) -> bool:
|
|
||||||
"""
|
|
||||||
Update the session with the user record if the credenteials are valid.
|
|
||||||
"""
|
|
||||||
if not (username and password):
|
|
||||||
app.web.logger.debug("Need both username and password to login")
|
|
||||||
return False
|
|
||||||
|
|
||||||
user = app.db.User.get(where("name") == username)
|
|
||||||
if not user:
|
|
||||||
app.web.logger.debug(f"No user matching {username}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not user.check_credentials(username, password):
|
|
||||||
app.web.logger.debug(f"Invalid credentials for {username}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
app.web.logger.debug(f"Session for {user.name} ({user.doc_id}) started.")
|
|
||||||
g.user = user
|
|
||||||
session["user_id"] = g.user.doc_id
|
|
||||||
session["user"] = dict(g.user.serialize())
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@app.web.route("/login", methods=["GET", "POST"])
|
@app.web.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
app.web.session_interface.regenerate(session)
|
app.web.session_interface.regenerate(session)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username")
|
username = request.form.get("username")
|
||||||
password = request.form.get("password")
|
password = request.form.get("password")
|
||||||
if do_login(username, password):
|
user = app.authenticate(username, password)
|
||||||
|
if user:
|
||||||
|
g.user = user
|
||||||
|
session["user_id"] = g.user.doc_id
|
||||||
|
session["user"] = dict(g.user.serialize())
|
||||||
|
app.web.logger.debug(f"Session for {user.name} ({user.doc_id}) started.")
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
g.messages.append(f"Invalid login for {username}")
|
g.messages.append(f"Invalid login for {username}")
|
||||||
return rendered(schema.Page(name="Login", title="Please enter your login details"), "login.html")
|
return rendered(schema.Page(name="Login", title="Please enter your login details"), "login.html")
|
||||||
|
@ -136,7 +111,6 @@ def logout():
|
||||||
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"], defaults={"table": "Page"})
|
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"], defaults={"table": "Page"})
|
||||||
def view(table, path):
|
def view(table, path):
|
||||||
parent = get_parent(table, relative_uri())
|
parent = get_parent(table, relative_uri())
|
||||||
print(parent)
|
|
||||||
return rendered(get_page(request.path, table=table, create_okay=(parent and parent.doc_id is not None)))
|
return rendered(get_page(request.path, table=table, create_okay=(parent and parent.doc_id is not None)))
|
||||||
|
|
||||||
|
|
||||||
|
@ -150,6 +124,8 @@ def edit(table, path):
|
||||||
|
|
||||||
# get or create the docoument at this uri
|
# get or create the docoument at this uri
|
||||||
page = get_page(uri, table=table, create_okay=True)
|
page = get_page(uri, table=table, create_okay=True)
|
||||||
|
if not app.authorize(g.user, page, schema.Permissions.WRITE):
|
||||||
|
return Response("Permission denied.", status=403)
|
||||||
save_data = getattr(forms, table)(page, request.form).prepare()
|
save_data = getattr(forms, table)(page, request.form).prepare()
|
||||||
|
|
||||||
# editing existing document
|
# editing existing document
|
||||||
|
@ -159,7 +135,7 @@ def edit(table, path):
|
||||||
return rendered(app.db.save(save_data))
|
return rendered(app.db.save(save_data))
|
||||||
|
|
||||||
# saving a new document
|
# saving a new document
|
||||||
return rendered(app.add_member(parent, save_data))
|
return rendered(parent.add_member(save_data))
|
||||||
|
|
||||||
|
|
||||||
@app.web.before_request
|
@app.web.before_request
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from grung.db import GrungDB
|
||||||
|
from tinydb import where
|
||||||
|
from tinydb.storages import MemoryStorage
|
||||||
|
|
||||||
import ttfrog.app
|
import ttfrog.app
|
||||||
from ttfrog import schema
|
from ttfrog import schema
|
||||||
from grung.db import GrungDB
|
|
||||||
from tinydb.storages import MemoryStorage
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -43,23 +44,38 @@ def test_permissions(app):
|
||||||
players = app.db.save(schema.Group(name="players", members=[john]))
|
players = app.db.save(schema.Group(name="players", members=[john]))
|
||||||
notes = app.db.save(schema.Page(name="notes"))
|
notes = app.db.save(schema.Page(name="notes"))
|
||||||
|
|
||||||
# default read-only
|
# default no access
|
||||||
assert players.can_read(notes, app.db)
|
assert not players.can_read(notes)
|
||||||
assert not players.can_write(notes, app.db)
|
assert not players.can_write(notes)
|
||||||
assert not players.can_delete(notes, app.db)
|
assert not players.can_delete(notes)
|
||||||
|
assert not john.can_read(notes)
|
||||||
|
assert not john.can_write(notes)
|
||||||
|
assert not john.can_delete(notes)
|
||||||
|
|
||||||
# set to rw, no delete
|
# set to rw, no delete
|
||||||
notes.set_permissions(players, [schema.Permissions.READ, schema.Permissions.WRITE], app.db)
|
notes.set_permissions(players, [schema.Permissions.READ, schema.Permissions.WRITE])
|
||||||
assert players.can_read(notes, app.db)
|
notes = app.db.Page.get(doc_id=notes.doc_id)
|
||||||
assert players.can_write(notes, app.db)
|
|
||||||
assert not players.can_delete(notes, app.db)
|
assert players.can_read(notes)
|
||||||
|
assert players.can_write(notes)
|
||||||
|
assert not players.can_delete(notes)
|
||||||
|
|
||||||
# members of the group inherit group permissions
|
# members of the group inherit group permissions
|
||||||
assert john.can_read(notes, app.db)
|
assert john.can_read(notes)
|
||||||
assert john.can_write(notes, app.db)
|
assert john.can_write(notes)
|
||||||
assert not john.can_delete(notes, app.db)
|
assert not john.can_delete(notes)
|
||||||
|
|
||||||
# permissions are the union of user + group permissions
|
# permissions are the union of user + group permissions
|
||||||
notes.set_permissions(john, [schema.Permissions.DELETE], app.db)
|
notes.set_permissions(john, [schema.Permissions.DELETE])
|
||||||
assert not players.can_delete(notes, app.db)
|
assert not players.can_delete(notes)
|
||||||
assert john.can_delete(notes, app.db)
|
assert john.can_delete(notes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap(app):
|
||||||
|
from ttfrog.bootstrap import bootstrap
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
|
||||||
|
admins = app.db.Group.get(where("name") == "administrators")
|
||||||
|
admin = app.db.User.get(where("name") == "admin")
|
||||||
|
assert admin.reference in admins.members
|
||||||
|
|
Loading…
Reference in New Issue
Block a user