Start working on perms, roles, namespaces

This commit is contained in:
digimint 2025-11-17 08:28:50 -06:00
parent feb4366a54
commit 1204e50c52
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
14 changed files with 497 additions and 73 deletions

View file

@ -5,4 +5,5 @@
- flask-login (for logging in)
- wtforms (for parsing form data)
- psycopg2 (for postgresql)
- pyargon2 (for HashV1)
- pyargon2 (for HashV1)
- humanize (for generating human-readable timedeltas)

View file

@ -0,0 +1,150 @@
from enum import IntFlag
from taskflower.db import db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserRole, UserToNamespaceRole
from taskflower.db.model.user import User
from taskflower.types.either import Either, Left, Right
class NamespacePermissionType(IntFlag):
NO_PERMS = 0
READ = (1 << 0)
ADMINISTRATE = (1 << 1)
CREATE_IN = (1 << 2)
COMPLETE_OWN = (1 << 3)
COMPLETE_ALL = (1 << 4)
UNCOMPLETE_OWN = (1 << 5)
UNCOMPLETE_ALL = (1 << 6)
EDIT_OWN = (1 << 7)
EDIT_ALL = (1 << 8)
DELETE_OWN = (1 << 9)
DELETE_ALL = (1 << 10)
EDIT_ROLES = (1 << 11)
class UserPermissionType(IntFlag):
NO_PERMS = 0
READ_PROFILE = (1 << 0)
EDIT_DISPLAY_NAME = (1 << 1)
EDIT_USERNAME = (1 << 2)
EDIT_PROFILE = (1 << 3)
SEE_ALL_TASKS_OF = (1 << 4)
COMPLETE_ALL_TASKS_OF = (1 << 5)
UNCOMPLETE_ALL_TASKS_OF = (1 << 6)
EDIT_ALL_TASKS_OF = (1 << 7)
DELETE_ALL_TASKS_OF = (1 << 8)
EDIT_ROLES = (1 << 9)
ADMINISTRATE = (1 << 10)
SELF_USER_PERMISSIONS = (
UserPermissionType.READ_PROFILE
| UserPermissionType.EDIT_DISPLAY_NAME
| UserPermissionType.EDIT_USERNAME
| UserPermissionType.EDIT_PROFILE
| UserPermissionType.SEE_ALL_TASKS_OF
| UserPermissionType.COMPLETE_ALL_TASKS_OF
| UserPermissionType.UNCOMPLETE_ALL_TASKS_OF
| UserPermissionType.EDIT_ALL_TASKS_OF
| UserPermissionType.DELETE_ALL_TASKS_OF
| UserPermissionType.EDIT_ROLES
| UserPermissionType.ADMINISTRATE
)
SELF_NAMESPACE_PERMISSIONS = (
NamespacePermissionType.READ
| NamespacePermissionType.ADMINISTRATE
| NamespacePermissionType.CREATE_IN
| NamespacePermissionType.COMPLETE_OWN
| NamespacePermissionType.COMPLETE_ALL
| NamespacePermissionType.UNCOMPLETE_OWN
| NamespacePermissionType.UNCOMPLETE_ALL
| NamespacePermissionType.EDIT_OWN
| NamespacePermissionType.EDIT_ALL
| NamespacePermissionType.DELETE_OWN
| NamespacePermissionType.DELETE_ALL
| NamespacePermissionType.EDIT_ROLES
)
def _create_user_role(user: User) -> Either[Exception, UserRole]:
try:
self_role = UserRole(
is_self=True, # pyright:ignore[reportCallIssue]
name='Self', # pyright:ignore[reportCallIssue]
description=f'Self-role for @user@{user.id}', # pyright:ignore[reportCallIssue]
permissions=int(SELF_USER_PERMISSIONS), # pyright:ignore[reportCallIssue]
perms_deny=0, # pyright:ignore[reportCallIssue]
priority=0, # pyright:ignore[reportCallIssue]
user=user.id # pyright:ignore[reportCallIssue]
)
db.session.add(self_role)
db.session.commit()
return Right(self_role)
except Exception as e:
return Left(e)
def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
try:
new_ns = Namespace(
name=f'@user@{user.id}\'s Namespace', # pyright:ignore[reportCallIssue]
description='Your default namespace!' # pyright:ignore[reportCallIssue]
)
db.session.add(new_ns)
db.session.commit()
return Right(new_ns)
except Exception as e:
return Left(e)
def _create_namespace_role(
namespace: Namespace
) -> Either[Exception, NamespaceRole]:
try:
new_ns_role = NamespaceRole(
name='Administrator', # pyright:ignore[reportCallIssue]
description='Default role for the namespace administrator.', # pyright:ignore[reportCallIssue]
permissions = SELF_NAMESPACE_PERMISSIONS, # pyright:ignore[reportCallIssue]
perms_deny = 0, # pyright:ignore[reportCallIssue]
priority = 0, # pyright:ignore[reportCallIssue]
namespace = namespace.id # pyright:ignore[reportCallIssue]
)
db.session.add(new_ns_role)
db.session.commit()
return Right(new_ns_role)
except Exception as e:
return Left(e)
def _associate_namespace_role(
user: User,
namespace_role: NamespaceRole
) -> Either[Exception, UserToNamespaceRole]:
try:
associate_ns_role = UserToNamespaceRole(
user=user.id, # pyright:ignore[reportCallIssue]
role=namespace_role.id # pyright:ignore[reportCallIssue]
)
db.session.add(associate_ns_role)
db.session.commit()
return Right(associate_ns_role)
except Exception as e:
return Left(e)
def initialize_user_permissions(user: User):
return _create_user_role(user).flat_map(
lambda _: _gen_user_namespace(user)
).flat_map(
lambda user_ns: _create_namespace_role(user_ns)
).flat_map(
lambda user_ns_role: _associate_namespace_role(
user,
user_ns_role
)
).map(
lambda _: user
)

View file

@ -0,0 +1,18 @@
from taskflower.db import db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
from taskflower.db.model.user import User
def get_namespaces_for_user(user: User):
return db.session.query(
Namespace
).join(
NamespaceRole,
Namespace.id == NamespaceRole.namespace
).join(
UserToNamespaceRole,
NamespaceRole.id == UserToNamespaceRole.role
).filter(
UserToNamespaceRole.user == user.id
).all()

View file

@ -0,0 +1,40 @@
from taskflower.auth.permission import NamespacePermissionType
from taskflower.db import db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
from taskflower.db.model.user import User
def resolve_perms_on_namespace(
user: User,
namespace: Namespace
) -> NamespacePermissionType:
roles = db.session.query(
NamespaceRole
).join(
Namespace,
NamespaceRole.namespace == Namespace.id
).join(
UserToNamespaceRole,
NamespaceRole.id == UserToNamespaceRole.role
).join(
User,
User.id == UserToNamespaceRole.user
).filter(
User.id == user.id
).filter(
Namespace.id == namespace.id
).order_by(
NamespaceRole.priority.desc()
).all()
perms: NamespacePermissionType = NamespacePermissionType.NO_PERMS
for role in roles:
perms = (
(perms | role.permissions)
& ~(role.perms_deny)
)
return perms

View file

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
class Namespace(db.Model):
__tablename__: str = 'namespace'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TaskToNamespace(db.Model):
__tablename__: str = 'task_to_namespace'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id'))

View file

@ -0,0 +1,43 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
class NamespaceRole(db.Model):
__tablename__: str = 'namespace_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
class UserToNamespaceRole(db.Model):
__tablename__: str = 'user_to_namespace_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
class UserRole(db.Model):
__tablename__: str = 'user_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
is_self: Mapped[bool] = mapped_column(Boolean, default=False) # Self-roles can't be assigned to anyone other than the user whose account they are associated with
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
class UserToUserRole(db.Model):
__tablename__: str = 'user_to_user_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id'))

View file

@ -46,7 +46,7 @@ class Task(db.Model, APISerializable):
__tablename__: str = 'task'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
due: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -0,0 +1,61 @@
.list{
width: 100%;
}
table.list{
border-collapse: collapse;
}
.list tr {
background-color: var(--table-row-bg-1);
color: var(--on-table-row);
}
.list tr:nth-child(odd) {
background-color: var(--table-row-bg-2);
color: var(--on-table-row);
}
.list .task-header-row {
border-bottom: 8px solid var(--bg);
}
.list td, .list th{
border-right: 4px solid var(--bg);
border-left: 4px solid var(--bg);
}
.list td {
padding-left: 1rem;
}
.list p {
margin: 0;
}
.list .detail-view {
display: none;
border-bottom: 4px solid var(--bg);
border-top: 1px solid var(--bg);
}
.list .detail-view-elem {
padding: 1rem;
}
.list .detail-view.shown {
display: table-row;
}
.list .detail-view-elem .small-details {
font-size: small;
font-style: italic;
}
.list .detail-view-elem h1 {
margin: 0;
}
.list .detail-view-elem .main-description {
margin-top: 0.5rem;
}

View file

@ -0,0 +1,15 @@
{% macro namespace_header() %}
<tr>
<th>Name</th>
<th>Description</th>
<th>Link</th>
</tr>
{% endmacro %}
{% macro inline_namespace(ns) %}
<tr id={{ "row-"~ns.id }}>
<td id={{ "name-"~ns.id }}>{{ ns.name }}</td>
<td id={{ "desc-"~ns.id }}>{{ ns.description }}</td>
<td id={{ "link-"~ns.id }}><a href={{ url_for("web.namespace.get", id=ns.id) }}>GO ➪</a></td>
</tr>
{% endmacro %}

View file

@ -0,0 +1,27 @@
{% extends "main.html" %}
{% from "namespace/_listentry.html" import inline_namespace, namespace_header %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}My Namespaces{% endblock %}
{% block main_content %}
<h1>My Namespaces</h1>
<table class="list">
<colgroup>
<col span="1"/>
<col span="1"/>
<col span="1"/>
</colgroup>
<tbody>
{{ namespace_header() }}
{% for ns in namespaces %}
{{ inline_namespace(ns) }}
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,18 +1,22 @@
{% extends "main.html" %}
{% from "task/_shorttask.html" import inline_task, inline_task_header %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}My Tasks{% endblock %}
{% block main_content %}
<h1>My Tasks</h1>
<table class="task-list">
<table class="list">
<colgroup>
<col span="1" style="width: 0;"/>
<col span="1"/>
<col span="1" style="width: 20%;"/>
</colgroup>
<tbody class="task-list">
<tbody class="list">
{{ inline_task_header() }}
{% for task in tasks %}
{{ inline_task(task) }}
@ -21,77 +25,13 @@
</table>
<style>
.task-list{
/* display: grid;
grid-template-columns: min-content 20% auto max-content; */
width: 100%;
}
table.task-list{
border-collapse: collapse;
}
.task-list tr {
background-color: var(--table-row-bg-1);
color: var(--on-table-row);
}
.task-list tr:nth-child(odd) {
background-color: var(--table-row-bg-2);
color: var(--on-table-row);
}
.task-list .task-header-row {
border-bottom: 8px solid var(--bg);
}
.task-list * td, .task-list * th{
border-right: 4px solid var(--bg);
border-left: 4px solid var(--bg);
}
.task-list * td {
padding-left: 1rem;
}
.task-list * .checkbox {
.list .checkbox {
padding: 1rem;
}
.task-list * .task-due {
.list .task-due {
padding-right: 1rem;
}
.task-list * p {
margin: 0;
}
.task-list * .detail-view {
display: none;
border-bottom: 4px solid var(--bg);
border-top: 1px solid var(--bg);
}
.task-list * .detail-view-elem {
padding: 1rem;
}
.task-list * .detail-view.shown {
display: table-row;
}
.task-list * .detail-view-elem .small-details {
font-size: small;
font-style: italic;
}
.task-list * .detail-view-elem h1 {
margin: 0;
}
.task-list * .detail-view-elem .main-description {
margin-top: 0.5rem;
}
</style>
<script>

View file

@ -1,10 +1,12 @@
from flask import Blueprint
from taskflower.web.auth import web_auth
from taskflower.web.namespace import web_namespace
from taskflower.web.task import web_tasks
from taskflower.web.user import web_user
from taskflower.web.auth import web_auth
web_base = Blueprint('web', __name__, url_prefix='/')
web_base.register_blueprint(web_tasks)
web_base.register_blueprint(web_user)
web_base.register_blueprint(web_auth)
web_base.register_blueprint(web_auth)
web_base.register_blueprint(web_namespace)

View file

@ -0,0 +1,102 @@
from dataclasses import dataclass
from flask import Blueprint, redirect, render_template, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.lookups import get_namespaces_for_user
from taskflower.auth.permission.resolve import resolve_perms_on_namespace
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.user import User
from taskflower.types.either import Either, Left, Right, gather_successes
web_namespace = Blueprint(
'namespace',
__name__,
'/templates',
url_prefix='/namespace'
)
@dataclass(frozen=True)
class NamespacePermsForUser:
read: bool
create_in: bool
complete_own: bool
complete_all: bool
uncomplete_own: bool
uncomplete_all: bool
edit_own: bool
edit_all: bool
delete_own: bool
delete_all: bool
edit_roles: bool
administrate: bool
@dataclass(frozen=True)
class NamespaceForUser():
id: int
name: str
description: str
perms: NamespacePermsForUser
@staticmethod
def from_user(ns: Namespace, user: User) -> Either[Exception, 'NamespaceForUser']:
perms = resolve_perms_on_namespace(
user,
ns
)
if not (
NamespacePermissionType.READ in perms
or NamespacePermissionType.ADMINISTRATE in perms
):
return Left(KeyError('No such namespace or insufficient permissions.'))
return Right(
NamespaceForUser(
ns.id,
ns.name,
ns.description,
NamespacePermsForUser(
NamespacePermissionType.READ in perms,
NamespacePermissionType.CREATE_IN in perms,
NamespacePermissionType.COMPLETE_OWN in perms,
NamespacePermissionType.COMPLETE_ALL in perms,
NamespacePermissionType.UNCOMPLETE_OWN in perms,
NamespacePermissionType.UNCOMPLETE_ALL in perms,
NamespacePermissionType.EDIT_OWN in perms,
NamespacePermissionType.EDIT_ALL in perms,
NamespacePermissionType.DELETE_OWN in perms,
NamespacePermissionType.DELETE_ALL in perms,
NamespacePermissionType.EDIT_ROLES in perms,
NamespacePermissionType.ADMINISTRATE in perms
)
)
)
@web_namespace.route('/')
@login_required
def all():
cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
namespace_list: list[Namespace] = get_namespaces_for_user(cur_usr)
namespaces_parsed = gather_successes([
NamespaceForUser.from_user(
ns, cur_usr
)
for ns in namespace_list
])
return render_template(
'namespace/list.html',
namespaces=namespaces_parsed
)
@web_namespace.route('/<int:id>')
@login_required
def get(id: int):
return redirect(url_for('web.namespace.all'))

View file

@ -5,6 +5,7 @@ from wtforms.validators import DataRequired, EqualTo, Length
from taskflower.auth import password_breach_count
from taskflower.auth.hash import make_hash_v1
from taskflower.auth.permission import initialize_user_permissions
from taskflower.db import db
from taskflower.db.model.user import User
from taskflower.types import ann
@ -254,10 +255,12 @@ def create_user_page():
if request.method == 'POST' and form_data.validate():
return _user_from_form(form_data).flat_map(
lambda usr: _add_to_db(usr)
).flat_map(
lambda usr: initialize_user_permissions(usr)
).side_effect(
lambda usr: login_user(usr)
).and_then(
lambda usr: redirect(url_for('web.task.all_tasks_view')),
lambda usr: redirect(url_for('web.task.all')),
lambda exc: response_from_exception(exc)
)