Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,13 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||
# live. The one secret that must NOT be stored in the database — the SMTP
|
||||
# password — is read from this environment variable instead. Leave it unset
|
||||
# until you have the SMTP details; the toggle stays effectively off (queued
|
||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||
# on and SMTP is configured.
|
||||
# SMTP_PASSWORD=your-smtp-app-password
|
||||
|
||||
43
server/alembic.ini
Normal file
43
server/alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
||||
# Alembic configuration for the Work Package Suite.
|
||||
# The database URL is NOT hard-coded here — env.py pulls it from the same place
|
||||
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
|
||||
# migrations always target the same database as the running app.
|
||||
[alembic]
|
||||
script_location = %(here)s/alembic
|
||||
prepend_sys_path = .
|
||||
# Use OS-native path separators on Windows dev machines.
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
63
server/alembic/env.py
Normal file
63
server/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Alembic environment for the Work Package Suite.
|
||||
|
||||
We reuse the application's own database configuration (server/db.py) so a
|
||||
migration always targets the same database the app would connect to — Postgres
|
||||
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
|
||||
No connection string is stored in alembic.ini.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Make the `server` package importable no matter where alembic is invoked from
|
||||
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
|
||||
# so the repo root is two directories up.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO = os.path.dirname(os.path.dirname(_HERE))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from server.db import Base, DATABASE_URL, engine # noqa: E402
|
||||
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# The app resolves its URL from the environment; feed the same value to Alembic.
|
||||
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
|
||||
context.configure(
|
||||
url=str(DATABASE_URL),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live connection, reusing the app's engine."""
|
||||
with engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
server/alembic/script.py.mako
Normal file
23
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
server/alembic/versions/.gitkeep
Normal file
0
server/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
"""user login lockout fields
|
||||
|
||||
Revision ID: 18373f14809e
|
||||
Revises: 47bbe76aa749
|
||||
Create Date: 2026-07-15 14:50:58.423834
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '18373f14809e'
|
||||
down_revision = '47bbe76aa749'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'locked_until')
|
||||
op.drop_column('users', 'failed_attempts')
|
||||
# ### end Alembic commands ###
|
||||
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""wp archived_at
|
||||
|
||||
Revision ID: 47bbe76aa749
|
||||
Revises: 4e094197c9aa
|
||||
Create Date: 2026-07-15 12:00:14.356398
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '47bbe76aa749'
|
||||
down_revision = '4e094197c9aa'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'archived_at')
|
||||
# ### end Alembic commands ###
|
||||
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""audit log
|
||||
|
||||
Revision ID: 4e094197c9aa
|
||||
Revises: c6af106a04da
|
||||
Create Date: 2026-07-15 10:12:52.859694
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4e094197c9aa'
|
||||
down_revision = 'c6af106a04da'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('actor', sa.String(length=200), nullable=False),
|
||||
sa.Column('action', sa.String(length=60), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=40), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('summary', sa.String(length=400), nullable=False),
|
||||
sa.Column('detail', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,63 @@
|
||||
"""assignment + settings + notifications
|
||||
|
||||
Revision ID: 57dec34f11cb
|
||||
Revises: ad8e6cc5de0f
|
||||
Create Date: 2026-07-15 16:43:09.230419
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '57dec34f11cb'
|
||||
down_revision = 'ad8e6cc5de0f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('app_settings',
|
||||
sa.Column('key', sa.String(length=80), nullable=False),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
op.create_table('notifications',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('kind', sa.String(length=40), nullable=False),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('subject', sa.String(length=300), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('link', sa.String(length=500), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error', sa.String(length=400), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'assignee_id')
|
||||
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
op.drop_table('app_settings')
|
||||
# ### end Alembic commands ###
|
||||
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""user token_version
|
||||
|
||||
Revision ID: ad8e6cc5de0f
|
||||
Revises: 18373f14809e
|
||||
Create Date: 2026-07-15 16:03:57.736556
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad8e6cc5de0f'
|
||||
down_revision = '18373f14809e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'token_version')
|
||||
# ### end Alembic commands ###
|
||||
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""baseline schema
|
||||
|
||||
Revision ID: c6af106a04da
|
||||
Revises:
|
||||
Create Date: 2026-07-15 08:21:07.450350
|
||||
|
||||
This is the initial baseline. It creates the current schema on a fresh database,
|
||||
and safely ADOPTS an existing database (one whose tables were created by the old
|
||||
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
|
||||
already present it records this revision without recreating anything. That means
|
||||
`alembic upgrade head` is safe to run on both new and existing deployments — no
|
||||
manual `alembic stamp` step required.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c6af106a04da'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa.inspect(bind).has_table("projects"):
|
||||
# Existing pre-Alembic database — adopt it as the baseline as-is.
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('comments',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('source', sa.String(length=40), nullable=False),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('step', sa.Integer(), nullable=True),
|
||||
sa.Column('author', sa.String(length=200), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('page', sa.String(length=200), nullable=False),
|
||||
sa.Column('extra', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
|
||||
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
|
||||
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
|
||||
op.create_table('projects',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('client', sa.String(length=300), nullable=False),
|
||||
sa.Column('division', sa.String(length=200), nullable=False),
|
||||
sa.Column('site', sa.String(length=300), nullable=False),
|
||||
sa.Column('sample', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('username', sa.String(length=120), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('project_members',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
|
||||
)
|
||||
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
|
||||
op.create_table('sops',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
|
||||
op.create_table('work_packages',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('number', sa.String(length=120), nullable=False),
|
||||
sa.Column('subject', sa.String(length=400), nullable=False),
|
||||
sa.Column('type', sa.String(length=120), nullable=False),
|
||||
sa.Column('status', sa.String(length=40), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
|
||||
op.drop_table('work_packages')
|
||||
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
|
||||
op.drop_table('sops')
|
||||
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
|
||||
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
|
||||
op.drop_table('project_members')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_projects_number'), table_name='projects')
|
||||
op.drop_table('projects')
|
||||
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_source'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
|
||||
op.drop_table('comments')
|
||||
# ### end Alembic commands ###
|
||||
469
server/app.py
469
server/app.py
@@ -9,24 +9,40 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models, auth
|
||||
from . import models, auth, notify
|
||||
|
||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# Schema management:
|
||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||
# • Production (Postgres) owns its schema through Alembic migrations, which run
|
||||
# at container start (`alembic upgrade head`, see Dockerfile / DEPLOYMENT.md).
|
||||
# We must NOT create_all there, or it would race/collide with the migration.
|
||||
if engine.dialect.name == "sqlite":
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
|
||||
# so enable them only on the SQLite dev fallback (production runs on Postgres).
|
||||
_docs_enabled = engine.dialect.name == "sqlite"
|
||||
app = FastAPI(
|
||||
title="Work Package Suite API",
|
||||
docs_url="/api/docs" if _docs_enabled else None,
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json" if _docs_enabled else None,
|
||||
)
|
||||
|
||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||
@@ -35,7 +51,7 @@ _origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||
if _origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
|
||||
)
|
||||
|
||||
|
||||
@@ -44,11 +60,33 @@ if _origins:
|
||||
# docs are exempt (see auth._needs_auth). This is the real security boundary —
|
||||
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
|
||||
# is always allowed so the browser can negotiate before sending credentials.
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _csrf_ok(request: Request) -> bool:
|
||||
"""CSRF defense-in-depth behind SameSite=Lax: when the browser sends an Origin
|
||||
on a state-changing request, it must be same-origin (or an allowed CORS origin).
|
||||
Non-browser clients (no Origin header) are unaffected."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True
|
||||
if _origins and origin in _origins:
|
||||
return True
|
||||
try:
|
||||
return urlparse(origin).netloc == request.headers.get("host", "")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
if method != "OPTIONS" and auth._needs_auth(path):
|
||||
if not auth.is_request_authenticated(request):
|
||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
|
||||
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@@ -56,6 +94,16 @@ def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# Clients may supply their own resource ids (offline-first). Constrain them to a
|
||||
# safe charset so an id can never carry HTML/JS that a UI might place in markup.
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
||||
|
||||
|
||||
def check_id(v: Optional[str]) -> None:
|
||||
if v and not _ID_RE.match(v):
|
||||
raise HTTPException(status_code=400, detail="Invalid id format")
|
||||
|
||||
|
||||
# ── Per-project access control ─────────────────────────────────────────────────
|
||||
# A non-admin user may only touch projects they're a member of (project_members).
|
||||
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
|
||||
@@ -72,8 +120,12 @@ def accessible_project_ids(db: Session, user: "models.User"):
|
||||
|
||||
|
||||
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||
if user.role == "admin" or project_id is None:
|
||||
if user.role == "admin":
|
||||
return
|
||||
if not project_id:
|
||||
# Non-admins may not read/mutate resources with no project assignment
|
||||
# (orphan/legacy rows); only admins can touch project-less data.
|
||||
raise HTTPException(status_code=403, detail="This resource is not assigned to a project you can access")
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user.id)
|
||||
@@ -104,6 +156,54 @@ def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
||||
|
||||
|
||||
# ── Audit trail ────────────────────────────────────────────────────────────────
|
||||
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
|
||||
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
|
||||
"""Append an audit-trail row in the CURRENT transaction so it commits
|
||||
atomically with the change it describes. `actor` may be a User or a username."""
|
||||
who = actor.username if isinstance(actor, models.User) else (actor or "")
|
||||
db.add(models.AuditLog(
|
||||
id=gen_id("ev"), actor=who, action=action, entity_type=entity_type,
|
||||
entity_id=entity_id or "", project_id=project_id, summary=(summary or "")[:400], detail=detail or {},
|
||||
))
|
||||
|
||||
|
||||
# ── Assignment ─────────────────────────────────────────────────────────────────
|
||||
def require_assignable(db: Session, user_id: str, project_id: Optional[str]) -> None:
|
||||
"""A WP can only be assigned to an active user who can access its project."""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u or not u.is_active:
|
||||
raise HTTPException(status_code=400, detail="Assignee is not a valid user")
|
||||
if u.role == "admin":
|
||||
return
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user_id) & (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="Assignee is not a member of this project")
|
||||
|
||||
|
||||
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
||||
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
|
||||
path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
|
||||
return (base + path) if base else path
|
||||
|
||||
|
||||
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
|
||||
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
|
||||
# customer IP inside the app, behind login).
|
||||
who = actor.full_name or actor.username
|
||||
name = assignee.full_name or assignee.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
|
||||
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
@@ -136,14 +236,35 @@ class WpIn(BaseModel):
|
||||
subject: str = ""
|
||||
type: str = ""
|
||||
status: str = "Draft"
|
||||
assignee_id: Optional[str] = None
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
email_enabled: Optional[bool] = None
|
||||
smtp_host: Optional[str] = None
|
||||
smtp_port: Optional[int] = None
|
||||
smtp_use_tls: Optional[bool] = None
|
||||
smtp_username: Optional[str] = None
|
||||
from_addr: Optional[str] = None
|
||||
from_name: Optional[str] = None
|
||||
app_base_url: Optional[str] = None
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
to: Optional[str] = None
|
||||
|
||||
|
||||
class StatusIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class ArchiveIn(BaseModel):
|
||||
archived: bool = True
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -191,22 +312,49 @@ class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class RoleIn(BaseModel):
|
||||
role: str # 'admin' | 'user'
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
||||
user = auth.find_user(db, body.username)
|
||||
# Always run a hash comparison to avoid leaking which usernames exist via
|
||||
# response timing; verify_password tolerates an empty hash.
|
||||
now = models.utcnow()
|
||||
# Always run the hash comparison first — even for missing or locked accounts —
|
||||
# so response timing doesn't leak which usernames exist. verify_password
|
||||
# tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
locked = user.locked_until if user else None
|
||||
if locked is not None and locked.tzinfo is None:
|
||||
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
|
||||
if locked is not None and locked > now:
|
||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||
if not user or not valid:
|
||||
if user:
|
||||
user.failed_attempts = (user.failed_attempts or 0) + 1
|
||||
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
||||
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
||||
user.failed_attempts = 0
|
||||
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
|
||||
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
user.last_login_at = models.utcnow()
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
@@ -226,13 +374,18 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Keep this session logged in by re-issuing a cookie carrying the new version.
|
||||
auth.set_session_cookie(response, request, auth.create_token(user))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -245,8 +398,9 @@ def list_users(_admin: models.User = Depends(auth.require_admin), db: Session =
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
if auth.find_user(db, body.username):
|
||||
@@ -260,6 +414,7 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
||||
role=body.role,
|
||||
)
|
||||
db.add(u)
|
||||
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
@@ -270,9 +425,11 @@ def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.Use
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -285,10 +442,43 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
|
||||
if u.id == admin.id and not body.is_active:
|
||||
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||
u.is_active = body.is_active
|
||||
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
||||
summary=u.username, detail={"is_active": bool(body.is_active)})
|
||||
db.commit()
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/role")
|
||||
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Change a user's role (admin ↔ user). Admins can do this at any time.
|
||||
Guards: you can't change your own role (avoids self-lockout), and the last
|
||||
remaining admin can't be demoted (keeps the app manageable)."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot change your own role")
|
||||
if u.role == "admin" and body.role != "admin":
|
||||
other_admins = db.scalars(
|
||||
select(models.User.id).where(
|
||||
(models.User.role == "admin")
|
||||
& (models.User.id != u.id)
|
||||
& (models.User.is_active.is_(True))
|
||||
)
|
||||
).all()
|
||||
if not other_admins:
|
||||
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
|
||||
old_role = u.role
|
||||
u.role = body.role
|
||||
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
|
||||
detail={"from": old_role, "to": body.role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/auth/users/{user_id}")
|
||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
@@ -296,6 +486,7 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
return {"deleted": user_id}
|
||||
@@ -334,6 +525,7 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
is_new = proj is None
|
||||
if not is_new:
|
||||
@@ -349,6 +541,8 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
|
||||
proj.sample = body.sample
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
|
||||
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
|
||||
db.commit()
|
||||
# A project created by a non-admin auto-grants its creator access.
|
||||
if is_new and user.role != "admin":
|
||||
@@ -388,10 +582,12 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
require_project_access(db, user, body.project_id)
|
||||
sop = db.get(models.Sop, body.id) if body.id else None
|
||||
if sop is not None:
|
||||
require_project_access(db, user, sop.project_id)
|
||||
is_new = sop is None
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
@@ -401,6 +597,9 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
|
||||
sop.complete = body.complete
|
||||
sop.created_by = body.created_by or sop.created_by
|
||||
sop.data = body.data
|
||||
log_event(db, user, "completed" if body.complete else ("created" if is_new else "updated"),
|
||||
"sop", sop.id, project_id=sop.project_id, summary=(sop.name or sop.number or sop.id),
|
||||
detail={"complete": bool(body.complete)})
|
||||
db.commit()
|
||||
db.refresh(sop)
|
||||
return sop.to_dict()
|
||||
@@ -447,6 +646,8 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
|
||||
summary=(sop.name or sop.number or sop.id))
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
@@ -454,11 +655,17 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
check_id(body.parent_id)
|
||||
check_id(body.assignee_id)
|
||||
require_project_access(db, user, body.project_id)
|
||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||
if wp is not None:
|
||||
require_project_access(db, user, wp.project_id)
|
||||
is_new = wp is None
|
||||
old_status = None if is_new else wp.status
|
||||
old_assignee = None if is_new else wp.assignee_id
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
@@ -469,19 +676,52 @@ def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db
|
||||
wp.subject = body.subject
|
||||
wp.type = body.type
|
||||
wp.status = body.status
|
||||
new_assignee = body.assignee_id or None
|
||||
if new_assignee:
|
||||
require_assignable(db, new_assignee, body.project_id)
|
||||
wp.assignee_id = new_assignee
|
||||
wp.created_by = body.created_by or wp.created_by
|
||||
wp.data = body.data
|
||||
if is_new:
|
||||
_act, _detail = "created", {"status": wp.status}
|
||||
elif old_status != wp.status:
|
||||
_act, _detail = "status_changed", {"from": old_status, "to": wp.status}
|
||||
else:
|
||||
_act, _detail = "updated", {"status": wp.status}
|
||||
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail=_detail)
|
||||
# Notify a newly-assigned owner (skip self-assignment).
|
||||
notif = None
|
||||
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
|
||||
assignee = db.get(models.User, new_assignee)
|
||||
if assignee:
|
||||
link = wp_link(db, wp)
|
||||
notif = notify.enqueue(
|
||||
db, user=assignee, kind="wp_assigned",
|
||||
subject=f"You were assigned {wp.number or 'a work package'}",
|
||||
body=assign_body(assignee, wp, user, link),
|
||||
link=link, wp_id=wp.id, project_id=wp.project_id,
|
||||
)
|
||||
log_event(db, user, "assigned", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"to": assignee.username})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
if notif is not None:
|
||||
background_tasks.add_task(notify.deliver, notif.id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/wps")
|
||||
def list_wps(
|
||||
response: Response,
|
||||
project_id: Optional[str] = Query(None),
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None, description="search number / subject / type"),
|
||||
archived: str = Query("exclude", description="exclude (default) | only | all"),
|
||||
limit: Optional[int] = Query(None, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
full: bool = Query(False),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -495,8 +735,25 @@ def list_wps(
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if status:
|
||||
stmt = stmt.where(models.WorkPackage.status == status)
|
||||
if archived == "only":
|
||||
stmt = stmt.where(models.WorkPackage.archived_at.is_not(None))
|
||||
elif archived != "all":
|
||||
stmt = stmt.where(models.WorkPackage.archived_at.is_(None)) # default: hide archived
|
||||
if q and q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
models.WorkPackage.number.ilike(like)
|
||||
| models.WorkPackage.subject.ilike(like)
|
||||
| models.WorkPackage.type.ilike(like)
|
||||
)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
# Report the pre-pagination total so the client can build a pager.
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
response.headers["X-Total-Count"] = str(total or 0)
|
||||
stmt = stmt.order_by(models.WorkPackage.updated_at.desc()).offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
rows = db.scalars(stmt).all()
|
||||
# full=true includes the data JSON (full package document) so the creator can
|
||||
# rehydrate everything in one request; default stays lean for listing.
|
||||
return [(w.to_dict() if full else w.summary()) for w in rows]
|
||||
@@ -507,7 +764,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
stmt = select(models.WorkPackage)
|
||||
stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None))
|
||||
if project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
@@ -560,6 +817,8 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
@@ -579,6 +838,8 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
|
||||
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
|
||||
wp.status = "Issued"
|
||||
wp.issued_at = models.utcnow()
|
||||
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"to": "Issued"})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
@@ -590,16 +851,147 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
old_status = wp.status
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
if old_status != body.status:
|
||||
log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/archive")
|
||||
def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Archive (or unarchive) a Work Package — hides it from the default lists and
|
||||
the dashboard without deleting it. Kept for the record on long-running jobs."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
was_archived = wp.archived_at is not None
|
||||
if body.archived and not was_archived:
|
||||
wp.archived_at = models.utcnow()
|
||||
log_event(db, user, "archived", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
elif not body.archived and was_archived:
|
||||
wp.archived_at = None
|
||||
log_event(db, user, "unarchived", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
# ── Audit trail (history) ──────────────────────────────────────────────────────
|
||||
@app.get("/api/audit")
|
||||
def list_audit(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""History / audit trail. Pass entity_type+entity_id for one item's history,
|
||||
or project_id for a project activity feed. Scoped to the caller's project
|
||||
access; admins additionally see project-less events (user management)."""
|
||||
stmt = select(models.AuditLog)
|
||||
if entity_type:
|
||||
stmt = stmt.where(models.AuditLog.entity_type == entity_type)
|
||||
if entity_id:
|
||||
stmt = stmt.where(models.AuditLog.entity_id == entity_id)
|
||||
if action:
|
||||
stmt = stmt.where(models.AuditLog.action == action)
|
||||
if project_id:
|
||||
require_project_access(db, user, project_id)
|
||||
stmt = stmt.where(models.AuditLog.project_id == project_id)
|
||||
# Non-admins only ever see events tied to a project they can access.
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is not None:
|
||||
stmt = stmt.where(models.AuditLog.project_id.in_(ids))
|
||||
rows = db.scalars(stmt.order_by(models.AuditLog.at.desc()).limit(limit).offset(offset)).all()
|
||||
return [e.to_dict() for e in rows]
|
||||
|
||||
|
||||
# ── Settings (admin) ────────────────────────────────────────────────────────────
|
||||
@app.get("/api/settings")
|
||||
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
return notify.public_settings(db)
|
||||
|
||||
|
||||
@app.put("/api/settings")
|
||||
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
saved = notify.save_settings(db, patch)
|
||||
log_event(db, admin, "settings_updated", "settings", "notifications",
|
||||
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
|
||||
db.commit()
|
||||
return notify.public_settings(db)
|
||||
|
||||
|
||||
@app.post("/api/settings/test-email")
|
||||
def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
s = notify.get_settings(db)
|
||||
if not notify.smtp_ready(s):
|
||||
raise HTTPException(status_code=400, detail="Set the SMTP host and From address first.")
|
||||
to = (body.to or admin.email or "").strip()
|
||||
if not to:
|
||||
raise HTTPException(status_code=400, detail="No recipient — add an email to your account or pass 'to'.")
|
||||
try:
|
||||
notify.send_email(s, to, "Work Package Suite — test email",
|
||||
"This is a test from the Work Package Suite. If you got this, SMTP is working.")
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"Send failed: {e}")
|
||||
return {"ok": True, "to": to}
|
||||
|
||||
|
||||
# ── Notifications + project members ─────────────────────────────────────────────
|
||||
@app.get("/api/notifications")
|
||||
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Notification)
|
||||
if not (all and user.role == "admin"):
|
||||
stmt = stmt.where(models.Notification.user_id == user.id)
|
||||
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
|
||||
return [n.to_dict() for n in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/members")
|
||||
def project_members(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Users who can be assigned WPs on this project — its members plus admins."""
|
||||
require_project_access(db, user, project_id)
|
||||
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
|
||||
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
|
||||
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all()
|
||||
out, seen = [], set()
|
||||
for u in list(members) + list(admins):
|
||||
if u.id in seen or not u.is_active:
|
||||
continue
|
||||
seen.add(u.id)
|
||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email})
|
||||
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
# ── Comments / feedback ──────────────────────────────────────────────────────
|
||||
def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
|
||||
# A comment tied to a WP/SOP requires access to that resource's project, so
|
||||
# a user can't write into another project's review thread.
|
||||
if body.wp_id:
|
||||
wp = db.get(models.WorkPackage, body.wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
elif body.sop_id:
|
||||
sop = db.get(models.Sop, body.sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
extra = body.model_extra or {}
|
||||
c = models.Comment(
|
||||
id=gen_id("c"),
|
||||
@@ -607,7 +999,9 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
sop_id=body.sop_id,
|
||||
wp_id=body.wp_id,
|
||||
step=body.step,
|
||||
author=(body.author or body.name or "Anonymous"),
|
||||
# Attribution comes from the authenticated session, NEVER the client
|
||||
# payload — otherwise comments could be forged as another user.
|
||||
author=(user.full_name or user.username),
|
||||
text=body.text or "",
|
||||
page=body.page or "",
|
||||
extra=extra,
|
||||
@@ -619,14 +1013,14 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
|
||||
|
||||
@app.post("/api/comments")
|
||||
def create_comment(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
def create_comment(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db, user)
|
||||
|
||||
|
||||
# Alias so the existing client (which posts to /api/feedback) keeps working.
|
||||
@app.post("/api/feedback")
|
||||
def create_feedback(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db, user)
|
||||
|
||||
|
||||
@app.get("/api/comments")
|
||||
@@ -635,6 +1029,7 @@ def list_comments(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
wp_id: Optional[str] = Query(None),
|
||||
step: Optional[int] = Query(None),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.Comment)
|
||||
@@ -646,6 +1041,22 @@ def list_comments(
|
||||
stmt = stmt.where(models.Comment.wp_id == wp_id)
|
||||
if step is not None:
|
||||
stmt = stmt.where(models.Comment.step == step)
|
||||
# Non-admins see general app feedback plus comments on WPs/SOPs in their own
|
||||
# projects only — never another project's review threads.
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is not None:
|
||||
acc_wp = select(models.WorkPackage.id).where(models.WorkPackage.project_id.in_(ids))
|
||||
acc_sop = select(models.Sop.id).where(models.Sop.project_id.in_(ids))
|
||||
# The "general feedback" branch is ONLY for comments not tied to any
|
||||
# WP/SOP — otherwise a project-scoped comment tagged source=home_feedback
|
||||
# by the client would leak across projects. Project-tied comments are
|
||||
# visible strictly by project membership.
|
||||
stmt = stmt.where(
|
||||
((models.Comment.source == "home_feedback")
|
||||
& models.Comment.wp_id.is_(None) & models.Comment.sop_id.is_(None))
|
||||
| (models.Comment.wp_id.in_(acc_wp))
|
||||
| (models.Comment.sop_id.in_(acc_sop))
|
||||
)
|
||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||
return [c.to_dict() for c in rows]
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .db import get_db, DATABASE_URL
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
@@ -40,6 +40,29 @@ JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
@@ -55,13 +78,24 @@ def _load_secret() -> str:
|
||||
s = os.getenv("AUTH_SECRET_KEY")
|
||||
if s:
|
||||
return s
|
||||
# No secret configured: generate an ephemeral one so the app still runs in
|
||||
# dev. Sessions won't survive a restart, and this is unsafe across multiple
|
||||
# workers — production must set AUTH_SECRET_KEY.
|
||||
# No key configured. In production (a real database is configured via
|
||||
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
|
||||
# sessions with a throwaway key that silently rotates on every restart. In
|
||||
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
|
||||
# runs zero-config.
|
||||
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
|
||||
# condition db.py uses to pick Postgres, so we don't wrongly block a
|
||||
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
|
||||
is_prod = not str(DATABASE_URL).startswith("sqlite")
|
||||
if is_prod:
|
||||
raise RuntimeError(
|
||||
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
|
||||
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
|
||||
"(see server/.env.example / DEPLOYMENT.md)."
|
||||
)
|
||||
log.warning(
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
|
||||
"Logins will reset on restart and break across multiple workers. "
|
||||
"Set AUTH_SECRET_KEY in the environment for production."
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
|
||||
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@@ -92,6 +126,7 @@ def create_token(user: "models.User") -> str:
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
@@ -163,6 +198,10 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||
# Session revocation: a mismatch means the token was invalidated (e.g. the
|
||||
# password was changed after this token was issued).
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -30,15 +30,16 @@ def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None) -> str:
|
||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
if len(pw) < 8:
|
||||
sys.exit("Password must be at least 8 characters.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
@@ -75,7 +76,7 @@ def cmd_list(args) -> None:
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
|
||||
@@ -92,7 +92,13 @@ class WorkPackage(Base):
|
||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||
type: Mapped[str] = mapped_column(String(120), default="")
|
||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||
# The accountable owner (a user id), for "My Work Packages" + assignment
|
||||
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Archived packages are hidden from the default lists/dashboard but kept for
|
||||
# the record (years-long projects accumulate hundreds of closed WPs).
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -102,7 +108,9 @@ class WorkPackage(Base):
|
||||
return {
|
||||
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
|
||||
"issued_at": _iso(self.issued_at),
|
||||
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
@@ -127,6 +135,12 @@ class User(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||
# change). The value is embedded in the JWT and re-checked on every request.
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
@@ -176,5 +190,74 @@ class Comment(Base):
|
||||
}
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only history: who changed what, when. Rows are written inside the
|
||||
same transaction as the change they describe (see server/app.py: log_event),
|
||||
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
||||
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
||||
|
||||
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
||||
of the thing it describes (you still want "who deleted WP01, and when")."""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
||||
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
||||
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
||||
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
||||
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
||||
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
||||
}
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||
the SMTP password are NOT stored here — they come from the environment."""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||
is written when something notable happens (e.g. a WP assignment); the email
|
||||
sender processes it only when email notifications are enabled AND SMTP is set —
|
||||
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
||||
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(300), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
link: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
||||
error: Mapped[str] = mapped_column(String(400), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
||||
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
||||
"status": self.status, "error": self.error,
|
||||
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
||||
}
|
||||
|
||||
|
||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
134
server/notify.py
Normal file
134
server/notify.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Notifications: admin-configurable email + an outbox.
|
||||
|
||||
Email notifications are OFF by default and controlled from the admin console (a
|
||||
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
|
||||
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
|
||||
and is NEVER stored in the database or shown in the UI.
|
||||
|
||||
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
||||
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
||||
background task. Notification bodies deliberately avoid customer IP: they carry a WP
|
||||
number and a deep link, not the work-package contents.
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.notify")
|
||||
|
||||
SETTINGS_KEY = "notifications"
|
||||
DEFAULTS = {
|
||||
"email_enabled": False, # master toggle — OFF until SMTP is sorted
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_use_tls": True,
|
||||
"smtp_username": "",
|
||||
"from_addr": "",
|
||||
"from_name": "Work Package Suite",
|
||||
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
|
||||
}
|
||||
|
||||
|
||||
def get_settings(db: Session) -> dict:
|
||||
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||
s = dict(DEFAULTS)
|
||||
if row and row.value:
|
||||
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
|
||||
return s
|
||||
|
||||
|
||||
def save_settings(db: Session, patch: dict) -> dict:
|
||||
cur = get_settings(db)
|
||||
for k in DEFAULTS:
|
||||
if k in patch and patch[k] is not None:
|
||||
cur[k] = patch[k]
|
||||
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||
if row:
|
||||
row.value = cur
|
||||
else:
|
||||
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
|
||||
db.commit()
|
||||
return cur
|
||||
|
||||
|
||||
def public_settings(db: Session) -> dict:
|
||||
"""Settings safe to return to the admin UI — no secrets."""
|
||||
s = get_settings(db)
|
||||
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
|
||||
return s
|
||||
|
||||
|
||||
def smtp_ready(s: dict) -> bool:
|
||||
return bool(s.get("smtp_host") and s.get("from_addr"))
|
||||
|
||||
|
||||
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
"""Send one email via SMTP. Raises on any failure (caller records it)."""
|
||||
if not to_addr:
|
||||
raise ValueError("no recipient email")
|
||||
msg = EmailMessage()
|
||||
from_name = s.get("from_name") or ""
|
||||
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
host = s["smtp_host"]
|
||||
port = int(s.get("smtp_port") or 587)
|
||||
user = s.get("smtp_username") or ""
|
||||
pw = os.getenv("SMTP_PASSWORD", "")
|
||||
with smtplib.SMTP(host, port, timeout=15) as srv:
|
||||
if s.get("smtp_use_tls", True):
|
||||
srv.starttls()
|
||||
if user:
|
||||
srv.login(user, pw)
|
||||
srv.send_message(msg)
|
||||
|
||||
|
||||
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
|
||||
commit — the caller commits with its own transaction. Returns the row."""
|
||||
s = get_settings(db)
|
||||
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
|
||||
n = models.Notification(
|
||||
id="ntf_" + uuid.uuid4().hex[:12],
|
||||
user_id=user.id, email=user.email or "", kind=kind,
|
||||
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
|
||||
link=link[:500], status="pending" if deliverable else "skipped",
|
||||
)
|
||||
db.add(n)
|
||||
return n
|
||||
|
||||
|
||||
def deliver(notif_id: str) -> None:
|
||||
"""Background task: send one pending notification, on its own DB session."""
|
||||
from .db import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
n = db.get(models.Notification, notif_id)
|
||||
if not n or n.status != "pending":
|
||||
return
|
||||
s = get_settings(db)
|
||||
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
|
||||
n.status = "skipped"
|
||||
db.commit()
|
||||
return
|
||||
try:
|
||||
send_email(s, n.email, n.subject, n.body)
|
||||
n.status = "sent"
|
||||
n.sent_at = models.utcnow()
|
||||
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
|
||||
n.status = "failed"
|
||||
n.error = str(e)[:400]
|
||||
log.warning("notification %s failed to send: %s", notif_id, e)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,9 +1,16 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
gunicorn>=21.2
|
||||
sqlalchemy>=2.0
|
||||
psycopg[binary]>=3.1
|
||||
pydantic>=2.6
|
||||
python-dotenv>=1.0
|
||||
bcrypt>=4.1 # password hashing
|
||||
PyJWT>=2.8 # signed session tokens
|
||||
# Pinned to exact versions for reproducible builds — no silent dependency drift
|
||||
# on every `docker compose up --build`. To update: bump a version here on purpose,
|
||||
# run `pip-audit` against the result, and test. For supply-chain integrity, the
|
||||
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with
|
||||
# `pip install --require-hashes`).
|
||||
fastapi==0.138.1
|
||||
uvicorn[standard]==0.49.0
|
||||
gunicorn==26.0.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5 # database migrations
|
||||
psycopg[binary]==3.3.4
|
||||
pydantic==2.13.4
|
||||
python-dotenv==1.2.2
|
||||
bcrypt==5.0.0 # password hashing
|
||||
PyJWT==2.13.0 # signed session tokens
|
||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||
|
||||
Reference in New Issue
Block a user