From 17d42c52a712195e85a3bdd44f6adbe5fdfc0540 Mon Sep 17 00:00:00 2001 From: patsy Date: Sat, 8 Aug 2026 04:14:25 +0200 Subject: [PATCH] phase 6 --- .env.example | 1 + README.md | 11 ++ app/__init__.py | 15 +- app/admin/__init__.py | 6 +- app/admin/routes.py | 14 ++ app/auth/models.py | 13 +- app/core/authorization.py | 24 +++ app/core/routes.py | 4 + app/static/css/style.css | 19 +++ app/tasks/routes.py | 14 ++ app/templates/admin/index.html | 36 +++++ app/templates/base.html | 3 + app/templates/core/403.html | 13 ++ config.py | 9 +- design.md | 4 +- .../c79217aea3e8_constrain_user_roles.py | 35 ++++ progress.md | 61 ++++++- tests/admin/test_admin.py | 149 ++++++++++++++++++ 18 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 app/admin/routes.py create mode 100644 app/core/authorization.py create mode 100644 app/templates/admin/index.html create mode 100644 app/templates/core/403.html create mode 100644 migrations/versions/c79217aea3e8_constrain_user_roles.py create mode 100644 tests/admin/test_admin.py diff --git a/.env.example b/.env.example index c0e3db9..e6b3223 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ STEADY_ENV=development SECRET_KEY=replace-with-a-long-random-value # DATABASE_URL may override the default absolute instance/steady.db location. +FEATURE_ADMIN=false diff --git a/README.md b/README.md index ac42191..36613bd 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,14 @@ color mode; enable a dyslexia-friendly local reading style; turn completion sound on or off; and save a future reminder-frequency preference. The browser reminder control checks capability only. It does not request notification permission, register a service worker, or schedule reminders. + +## Roles and admin placeholder + +Steady recognizes `user`, `admin`, `viewer`, and `coach` roles. Viewers can read +and export their own tasks but cannot mutate task data; the other three roles +retain writes to their own tasks. Ownership checks apply independently of role. + +The admin feature is absent by default. Setting `FEATURE_ADMIN=true` before the +application starts registers `/admin/`, which remains restricted to the `admin` +role. It is a data-free placeholder only: user management, role assignment, and +analytics are intentionally not implemented. diff --git a/app/__init__.py b/app/__init__.py index 1e18782..abc5863 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,7 @@ import os +from collections.abc import Mapping from pathlib import Path +from typing import Any from flask import Flask @@ -8,7 +10,10 @@ from config import CONFIGS from .extensions import csrf, db, login_manager, migrate -def create_app(config_name: str | None = None) -> Flask: +def create_app( + config_name: str | None = None, + config_overrides: Mapping[str, Any] | None = None, +) -> Flask: """Create and configure an isolated Steady application instance.""" app = Flask(__name__, instance_relative_config=True) @@ -22,6 +27,9 @@ def create_app(config_name: str | None = None) -> Flask: f"Unknown configuration {selected_config!r}; choose {valid_names}." ) from exc + if config_overrides: + app.config.from_mapping(config_overrides) + Path(app.instance_path).mkdir(parents=True, exist_ok=True) _validate_config(app) _initialize_extensions(app) @@ -56,3 +64,8 @@ def _register_blueprints(app: Flask) -> None: app.register_blueprint(auth_blueprint, url_prefix="/auth") app.register_blueprint(tasks_blueprint, url_prefix="/tasks") app.register_blueprint(settings_blueprint, url_prefix="/settings") + + if app.config["FEATURE_ADMIN"]: + from .admin import bp as admin_blueprint + + app.register_blueprint(admin_blueprint, url_prefix="/admin") diff --git a/app/admin/__init__.py b/app/admin/__init__.py index 7289393..5285eae 100644 --- a/app/admin/__init__.py +++ b/app/admin/__init__.py @@ -1,2 +1,6 @@ -"""Reserved package for the optional admin feature.""" +from flask import Blueprint + +bp = Blueprint("admin", __name__) + +from . import routes # noqa: E402, F401 diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..6dc5102 --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,14 @@ +from flask import render_template +from flask_login import login_required + +from app.core.authorization import roles_required + +from . import bp + + +@bp.get("/") +@login_required +@roles_required("admin") +def index(): + return render_template("admin/index.html") + diff --git a/app/auth/models.py b/app/auth/models.py index 6b9fe48..17b6d8e 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -1,14 +1,24 @@ from datetime import datetime, timezone from flask_login import UserMixin -from sqlalchemy import String +from sqlalchemy import CheckConstraint, String from sqlalchemy.orm import Mapped, mapped_column from werkzeug.security import check_password_hash, generate_password_hash from app.extensions import db, login_manager +USER_ROLES = ("user", "admin", "viewer", "coach") + + class User(UserMixin, db.Model): + __table_args__ = ( + CheckConstraint( + "role IN ('user', 'admin', 'viewer', 'coach')", + name="ck_user_role", + ), + ) + id: Mapped[int] = mapped_column(primary_key=True) username: Mapped[str] = mapped_column(String(80), unique=True, index=True) email: Mapped[str] = mapped_column(String(254), unique=True, index=True) @@ -36,4 +46,3 @@ def load_user(user_id: str) -> User | None: if not user_id.isdigit(): return None return db.session.get(User, int(user_id)) - diff --git a/app/core/authorization.py b/app/core/authorization.py new file mode 100644 index 0000000..e90e166 --- /dev/null +++ b/app/core/authorization.py @@ -0,0 +1,24 @@ +from collections.abc import Callable +from functools import wraps +from typing import Any + +from flask import abort +from flask_login import current_user + + +TASK_EDITOR_ROLES = ("user", "admin", "coach") + + +def roles_required(*allowed_roles: str): + """Require an authenticated user's role to match an explicit allow-list.""" + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def wrapped(*args: Any, **kwargs: Any): + if current_user.role not in allowed_roles: + abort(403) + return view(*args, **kwargs) + + return wrapped + + return decorator diff --git a/app/core/routes.py b/app/core/routes.py index 1fefcd5..6d69f32 100644 --- a/app/core/routes.py +++ b/app/core/routes.py @@ -12,3 +12,7 @@ def index(): def health(): return jsonify(status="ok") + +@bp.app_errorhandler(403) +def forbidden(_error): + return render_template("core/403.html"), 403 diff --git a/app/static/css/style.css b/app/static/css/style.css index 2bc5af3..adb3369 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -663,6 +663,21 @@ input:focus-visible { background: color-mix(in srgb, var(--lavender) 18%, var(--surface)); } +.admin-placeholder-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; +} + +.admin-boundary { + max-width: 48rem; + margin-top: 1.5rem; + padding: var(--space); + border-left: 0.35rem solid var(--lavender); + border-radius: 0.75rem; + background: var(--surface); +} + @keyframes gentle-arrival { from { opacity: 0; @@ -759,6 +774,10 @@ input { grid-template-columns: 1fr; } + .admin-placeholder-grid { + grid-template-columns: 1fr; + } + .undo-banner { align-items: flex-start; flex-direction: column; diff --git a/app/tasks/routes.py b/app/tasks/routes.py index 05a78dc..24f6a21 100644 --- a/app/tasks/routes.py +++ b/app/tasks/routes.py @@ -13,6 +13,8 @@ from flask import ( ) from flask_login import current_user, login_required +from app.core.authorization import TASK_EDITOR_ROLES, roles_required + from . import bp from .forms import ( ActionForm, @@ -122,6 +124,7 @@ def focus_task(task_id: int): @bp.route("/new", methods=["GET", "POST"]) @login_required +@roles_required(*TASK_EDITOR_ROLES) def create(): form = TaskForm() if form.validate_on_submit(): @@ -152,6 +155,7 @@ def detail(task_id: int): @bp.route("//edit", methods=["GET", "POST"]) @login_required +@roles_required(*TASK_EDITOR_ROLES) def edit(task_id: int): task = _owned_task_or_404(task_id) form = TaskForm(obj=task) @@ -171,6 +175,7 @@ def edit(task_id: int): @bp.post("//delete") @login_required +@roles_required(*TASK_EDITOR_ROLES) def delete(task_id: int): task = _owned_task_or_404(task_id) form = ActionForm() @@ -183,6 +188,7 @@ def delete(task_id: int): @bp.post("//subtasks") @login_required +@roles_required(*TASK_EDITOR_ROLES) def create_subtask(task_id: int): task = _owned_task_or_404(task_id) form = SubtaskForm() @@ -198,6 +204,7 @@ def create_subtask(task_id: int): @bp.post("//subtasks//toggle") @login_required +@roles_required(*TASK_EDITOR_ROLES) def toggle_subtask_status(task_id: int, subtask_id: int): task = _owned_task_or_404(task_id) form = ActionForm() @@ -212,6 +219,7 @@ def toggle_subtask_status(task_id: int, subtask_id: int): @bp.post("//focus-settings") @login_required +@roles_required(*TASK_EDITOR_ROLES) def update_focus_settings(task_id: int): task = _owned_task_or_404(task_id) form = FocusSettingsForm() @@ -232,6 +240,7 @@ def update_focus_settings(task_id: int): @bp.post("//chunks//toggle") @login_required +@roles_required(*TASK_EDITOR_ROLES) def toggle_focus_chunk(task_id: int, chunk_index: int): task = _owned_task_or_404(task_id) form = ActionForm() @@ -246,6 +255,7 @@ def toggle_focus_chunk(task_id: int, chunk_index: int): @bp.post("//complete") @login_required +@roles_required(*TASK_EDITOR_ROLES) def complete(task_id: int): task = _owned_task_or_404(task_id) form = ActionForm() @@ -278,6 +288,7 @@ def transfer(): @bp.post("/import/markdown") @login_required +@roles_required(*TASK_EDITOR_ROLES) def import_markdown_file(): form = MarkdownImportForm() if not form.validate_on_submit(): @@ -307,6 +318,7 @@ def export_json(): @bp.post("/import/json") @login_required +@roles_required(*TASK_EDITOR_ROLES) def import_json(): form = BackupImportForm() if not form.validate_on_submit(): @@ -328,6 +340,7 @@ def import_json(): @bp.post("/completed/clear") @login_required +@roles_required(*TASK_EDITOR_ROLES) def clear_completed(): form = ActionForm() if not form.validate_on_submit(): @@ -342,6 +355,7 @@ def clear_completed(): @bp.post("/completed/undo/") @login_required +@roles_required(*TASK_EDITOR_ROLES) def undo_completed_clear(batch_id): form = ActionForm() if not form.validate_on_submit(): diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 0000000..a8f1f69 --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Administration · Steady{% endblock %} + +{% block content %} +
+
+

Restricted placeholder

+

Administration

+

The admin boundary is active. Operational tools remain intentionally unavailable.

+
+
+ +
+
+

User management

+

Future scope: account review, role assignment, suspension, and audited administrative actions.

+ Not implemented +
+
+

Analytics

+

Future scope: privacy-preserving aggregate usage and operational health metrics.

+ Not implemented +
+
+ + +{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html index a4de111..f465987 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,6 +22,9 @@ Tasks Data Settings + {% if config.FEATURE_ADMIN and current_user.role == 'admin' %} + Admin + {% endif %} Hi, {{ current_user.username }}
diff --git a/app/templates/core/403.html b/app/templates/core/403.html new file mode 100644 index 0000000..17dd2c2 --- /dev/null +++ b/app/templates/core/403.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Access unavailable · Steady{% endblock %} + +{% block content %} +
+

Access unavailable

+

This area is not available for your role

+

Your account and tasks are unchanged.

+ Return home +
+{% endblock %} + diff --git a/config.py b/config.py index 136e34e..3e8b003 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,13 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent +def environment_flag(name: str, *, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + class Config: """Shared settings with secure production-oriented defaults.""" @@ -23,7 +30,7 @@ class Config: WTF_CSRF_TIME_LIMIT = 3600 MAX_CONTENT_LENGTH = 2 * 1024 * 1024 - FEATURE_ADMIN = False + FEATURE_ADMIN = environment_flag("FEATURE_ADMIN") FEATURE_API_V1 = False FEATURE_BROWSER_REMINDERS = False diff --git a/design.md b/design.md index 738b829..9a0761a 100644 --- a/design.md +++ b/design.md @@ -252,10 +252,10 @@ a, - [x] Browser Reminders (Web Push API Placeholder) [2] ### Phase 6: Admin Dashboard (Placeholder - Future) [3] -- [ ] Admin Route Placeholder [3] +- [x] Admin Route Placeholder [3] - [ ] User Management UI (Future) [3] - [ ] Analytics Dashboard (Future) [3] -- [ ] Role-based Access Control (Admin, User, Viewer, Coach) [3] +- [x] Role-based Access Control (Admin, User, Viewer, Coach) [3] ### Phase 7: Polish & Testing (Week 9-10) - [ ] Responsive Design Testing [2] diff --git a/migrations/versions/c79217aea3e8_constrain_user_roles.py b/migrations/versions/c79217aea3e8_constrain_user_roles.py new file mode 100644 index 0000000..ac190e3 --- /dev/null +++ b/migrations/versions/c79217aea3e8_constrain_user_roles.py @@ -0,0 +1,35 @@ +"""Constrain user roles + +Revision ID: c79217aea3e8 +Revises: 5ad737bbca5e +Create Date: 2026-08-08 04:08:30.449389 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c79217aea3e8' +down_revision = '5ad737bbca5e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_check_constraint( + 'ck_user_role', + "role IN ('user', 'admin', 'viewer', 'coach')", + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint('ck_user_role', type_='check') + + # ### end Alembic commands ### diff --git a/progress.md b/progress.md index ee94465..9408d04 100644 --- a/progress.md +++ b/progress.md @@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf ## Current Status -- Current phase: Phase 5 — Settings & Personalization -- Status: Complete +- Current phase: Phase 6 — Admin Dashboard Placeholder +- Status: Complete — placeholder scope - Last updated: 2026-08-08 -- Next phase: Phase 6 — Admin Dashboard Placeholder +- Next phase: Phase 7 — Polish & Testing ## Phase 1 — Core Infrastructure @@ -295,6 +295,59 @@ No broken Python requirements were found. Line-length, whitespace, and git diff checks completed successfully. ``` +## Phase 6 — Admin Dashboard Placeholder + +### Phase 6.1 — Role and feature boundary + +Added a database role constraint for `user`, `admin`, `viewer`, and `coach`; a reusable allow-list authorization decorator; a disabled-by-default `FEATURE_ADMIN` environment flag; and safe application-factory overrides for isolated feature testing. The admin Blueprint is imported and registered only when enabled. + +Added an admin-only placeholder dashboard and a calm `403` page. The dashboard explicitly labels user management and analytics as unimplemented and queries no user or analytics data. Admin navigation appears only when both the feature flag and role allow it. + +Why: a disabled route is safer than a visible placeholder protected only by convention. Role checks use an explicit allow-list, the database rejects unknown roles, and conditional imports keep the future feature absent from the route map when disabled. Factory overrides allow testing without changing deployment defaults. + +Next: enforce the viewer role as read-only across every task mutation, migrate the role constraint, and test all role boundaries. + +### Phase 6.2 — Viewer read-only enforcement + +Applied the shared task-editor allow-list (`user`, `admin`, `coach`) to every task-changing route: create, edit, delete, completion, subtasks, focus configuration, chunk toggles, both imports, clear, and undo. Viewer accounts retain authenticated access to lists, details, Focus Mode, export, and personal settings but receive `403` before mutation logic runs. + +Why: defining a viewer role without enforcing read-only behavior would create a misleading security promise. Centralizing the allow-list prevents individual routes from inventing different role semantics, while ownership checks continue to restrict editors to their own tasks. + +Next: generate the role-constraint migration and test disabled, anonymous, non-admin, admin, viewer, and coach behavior. + +The `Constrain user roles` migration was generated, reviewed, applied, and followed by a green 45-test regression run. + +### Phase 6.3 — Authorization coverage + +Added tests for default route absence, anonymous login redirection, `403` responses for user/viewer/coach roles, admin placeholder access, database rejection of unknown roles, viewer read access, all twelve viewer mutation denials, and retained coach write access to owned tasks. + +Why: checking only the admin landing page would leave lateral privilege gaps in task operations. Enumerating every mutation proves the viewer contract across uploads, recovery, focus actions, subtasks, and CRUD. The database test verifies defense in depth beneath route authorization. + +Next: run the focused role suite, then the complete regression and architecture checks. + +### Phase 6.4 — Final verification and scope boundary + +Completed the focused authorization suite and full regression, Alembic model comparison, default and enabled route discovery, Python compilation, JavaScript syntax validation, dependency consistency, line-length and whitespace scans, and patch-format validation. + +User-management and analytics operations remain deliberately unimplemented. The design labels both as future work, and adding role assignment, account suspension, or analytics collection would require explicit product requirements, audit logging, privacy rules, and recovery policy. Phase 6 therefore completes the safe placeholder and authorization boundary without creating speculative administrative power. + +Final results: + +```text +54 tests passed in 5.49s +All 9 focused authorization tests passed. +The default route map contains no admin endpoint. +The enabled route map contains the guarded /admin/ placeholder. +Alembic detected no pending model operations. +Python and JavaScript syntax validation completed successfully. +No broken Python requirements were found. +Line-length, whitespace, and git diff checks completed successfully. +``` + ## Next Work -Phase 6 is intentionally a future-facing admin placeholder. The next decision should be whether to implement only a safely disabled admin Blueprint and role guard as designed, or defer all admin surface area until concrete user-management requirements exist. +Phase 7 will focus on evidence-driven polish: responsive behavior, reduced-motion verification, accessibility auditing, performance checks, and deployment/documentation hardening. Future admin operations should remain separate until their requirements are approved. + +## Local Operational Setup + +- 2026-08-08: Promoted the sole local development account from `user` to `admin` and enabled `FEATURE_ADMIN=true` in the ignored local `.env` file. Verified through the Flask CLI route map that `/admin/` is registered. No account identifier or credential is recorded in this log. diff --git a/tests/admin/test_admin.py b/tests/admin/test_admin.py new file mode 100644 index 0000000..8f701c3 --- /dev/null +++ b/tests/admin/test_admin.py @@ -0,0 +1,149 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError + +from app import create_app +from app.auth.models import User +from app.auth.services import create_user +from app.extensions import db as database +from app.tasks.services import add_subtask, configure_focus, create_task + + +@pytest.fixture() +def admin_app(): + application = create_app("testing", {"FEATURE_ADMIN": True}) + with application.app_context(): + database.create_all() + yield application + database.session.remove() + database.drop_all() + + +@pytest.fixture() +def admin_client(admin_app): + return admin_app.test_client() + + +def login(client, email: str) -> None: + client.post( + "/auth/login", + data={"email": email, "password": "secure password"}, + ) + + +def user_with_role(username: str, role: str) -> User: + user = create_user( + username, + f"{username}@example.com", + "secure password", + ) + user.role = role + database.session.commit() + return user + + +def test_admin_feature_is_absent_by_default(app, client): + endpoints = {rule.endpoint for rule in app.url_map.iter_rules()} + + assert "admin.index" not in endpoints + assert client.get("/admin/").status_code == 404 + + +def test_enabled_admin_redirects_anonymous_users(admin_client): + response = admin_client.get("/admin/") + + assert response.status_code == 302 + assert "/auth/login" in response.headers["Location"] + + +@pytest.mark.parametrize("role", ["user", "viewer", "coach"]) +def test_non_admin_roles_receive_calm_forbidden_page(admin_client, role): + user = user_with_role(f"non_admin_{role}", role) + login(admin_client, user.email) + + response = admin_client.get("/admin/") + + assert response.status_code == 403 + assert b"not available for your role" in response.data + + +def test_admin_can_open_data_free_placeholder(admin_client): + user = user_with_role("admin_user", "admin") + login(admin_client, user.email) + + response = admin_client.get("/admin/") + + assert response.status_code == 200 + assert b"User management" in response.data + assert b"Analytics" in response.data + assert response.data.count(b"Not implemented") == 2 + assert b'href="/admin/"' in response.data + + +def test_database_rejects_unknown_roles(admin_app): + user = User( + username="invalid_role", + email="invalid-role@example.com", + role="superuser", + ) + user.set_password("secure password") + database.session.add(user) + + with pytest.raises(IntegrityError): + database.session.commit() + database.session.rollback() + + +def test_viewer_can_read_but_every_task_mutation_is_forbidden(client, db): + viewer = user_with_role("readonly_user", "viewer") + task = create_task( + viewer.id, + title="Readable task", + description=None, + status="not_started", + priority="normal", + due_date=None, + ) + subtask = add_subtask(task, "Readable step") + configure_focus(task, timer_minutes=5, context=None, chunk_minutes=[5]) + login(client, viewer.email) + + assert client.get("/tasks/").status_code == 200 + assert client.get(f"/tasks/{task.id}").status_code == 200 + assert client.get("/tasks/export/json").status_code == 200 + + operations = [ + ("get", "/tasks/new"), + ("get", f"/tasks/{task.id}/edit"), + ("post", f"/tasks/{task.id}/delete"), + ("post", f"/tasks/{task.id}/subtasks"), + ("post", f"/tasks/{task.id}/subtasks/{subtask.id}/toggle"), + ("post", f"/tasks/{task.id}/focus-settings"), + ("post", f"/tasks/{task.id}/chunks/0/toggle"), + ("post", f"/tasks/{task.id}/complete"), + ("post", "/tasks/import/markdown"), + ("post", "/tasks/import/json"), + ("post", "/tasks/completed/clear"), + ("post", f"/tasks/completed/undo/{uuid4()}"), + ] + for method, path in operations: + response = getattr(client, method)(path) + assert response.status_code == 403, path + + +def test_coach_retains_owned_task_write_access(client, db): + coach = user_with_role("coach_user", "coach") + login(client, coach.email) + + response = client.post( + "/tasks/new", + data={ + "title": "Coach-owned task", + "status": "not_started", + "priority": "normal", + "due_date": "", + }, + ) + + assert response.status_code == 302