From f0c61de280c0ed5cb1b27afc6804e6702fd47a3d Mon Sep 17 00:00:00 2001 From: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:29:16 +0000 Subject: [PATCH] security: add RBAC, HTTPS enforcement; add tests and CI pytest step --- modern/.github/workflows/ci.yml | 8 +- modern/backend/app.py | 24 ++++- modern/backend/models.py | 1 + modern/backend/rbac.py | 23 ++++ modern/backend/requirements_full.txt | 3 + modern/frontend/index.html | 13 ++- modern/frontend/manifest.json | 16 +-- modern/frontend/package.json | 32 +++--- modern/frontend/src/App.jsx | 20 ++-- modern/frontend/src/Guilds.jsx | 94 ++++++++--------- modern/frontend/src/Integrations.jsx | 152 +++++++++++++-------------- modern/frontend/src/Login.jsx | 42 ++++---- modern/frontend/src/main.jsx | 6 +- modern/frontend/sw.js | 12 +-- modern/tests/test_auth.py | 13 +++ modern/tests/test_integrations.py | 10 ++ 16 files changed, 271 insertions(+), 198 deletions(-) create mode 100644 modern/backend/rbac.py create mode 100644 modern/tests/test_auth.py create mode 100644 modern/tests/test_integrations.py diff --git a/modern/.github/workflows/ci.yml b/modern/.github/workflows/ci.yml index 2febfc2..d014e38 100644 --- a/modern/.github/workflows/ci.yml +++ b/modern/.github/workflows/ci.yml @@ -6,6 +6,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Check Python syntax - run: python -m py_compile modern/backend/server.py - - name: Check frontend package.json - run: cat modern/frontend/package.json + run: python -m py_compile modern/backend/*.py + - name: Run tests + run: | + python -m pip install -r modern/backend/requirements_full.txt + pytest -q diff --git a/modern/backend/app.py b/modern/backend/app.py index 28e6c11..ee31530 100644 --- a/modern/backend/app.py +++ b/modern/backend/app.py @@ -18,6 +18,18 @@ app.add_middleware( allow_headers=["*"], ) + +# HTTPS enforcement middleware (for production behind a proxy, check X-Forwarded-Proto) +@app.middleware('http') +async def https_redirect(request, call_next): + if os.getenv('FORCE_HTTPS', 'false').lower() == 'true': + proto = request.headers.get('x-forwarded-proto', request.url.scheme) + if proto != 'https': + from starlette.responses import RedirectResponse + url = request.url.replace(scheme='https') + return RedirectResponse(str(url)) + return await call_next(request) + @app.on_event('startup') def startup_event(): models.init_db() @@ -198,13 +210,16 @@ def list_integrations(): @app.delete('/api/v1/integrations/{integration_id}') -def delete_integration(integration_id: int): +def delete_integration(integration_id: int, request=None): db = models.SessionLocal() try: row = db.query(models.Integration).filter_by(id=integration_id).first() if not row: raise HTTPException(status_code=404, detail='integration not found') - db.delete(row) + # require owner or admin + from .rbac import require_owner_or_admin + require_owner_or_admin(row.user_id)(request) + db.delete(row) db.commit() return {'ok': True} finally: @@ -223,7 +238,10 @@ def sync_integration_to_habits(integration_id: int, payload: dict = Body({})): if not integration: raise HTTPException(status_code=404, detail='integration not found') - # Fetch events via existing events endpoint logic + # require owner or admin + from .rbac import require_owner_or_admin + require_owner_or_admin(integration.user_id)(None) + # Fetch events via existing events endpoint logic # Reuse token refresh + decrypt logic from oauth module token_row = db.query(models.OAuthToken).filter_by(integration_id=integration_id).order_by(models.OAuthToken.id.desc()).first() if not token_row: diff --git a/modern/backend/models.py b/modern/backend/models.py index b414cea..6464abc 100644 --- a/modern/backend/models.py +++ b/modern/backend/models.py @@ -15,6 +15,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, nullable=False, index=True) password_hash = Column(String) + role = Column(String, default='user') display_name = Column(String) created_at = Column(DateTime, server_default=func.current_timestamp()) updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) diff --git a/modern/backend/rbac.py b/modern/backend/rbac.py new file mode 100644 index 0000000..9aa98bf --- /dev/null +++ b/modern/backend/rbac.py @@ -0,0 +1,23 @@ +from fastapi import HTTPException +from .auth import get_current_user +from . import models + + +def require_role(min_role: str): + # Simple role hierarchy + hierarchy = {'user': 1, 'moderator': 2, 'admin': 3} + def _inner(request=None): + user = get_current_user(request) + if hierarchy.get(user.role, 0) < hierarchy.get(min_role, 0): + raise HTTPException(status_code=403, detail='insufficient role') + return user + return _inner + + +def require_owner_or_admin(resource_user_id: int): + def _inner(request=None): + user = get_current_user(request) + if user.id == resource_user_id or user.role == 'admin': + return user + raise HTTPException(status_code=403, detail='must be owner or admin') + return _inner diff --git a/modern/backend/requirements_full.txt b/modern/backend/requirements_full.txt index 4ae34d5..f1a86f4 100644 --- a/modern/backend/requirements_full.txt +++ b/modern/backend/requirements_full.txt @@ -6,3 +6,6 @@ python-dotenv requests cryptography boto3 +pytest +httpx +requests diff --git a/modern/frontend/index.html b/modern/frontend/index.html index 2041521..d91ff2b 100644 --- a/modern/frontend/index.html +++ b/modern/frontend/index.html @@ -1,12 +1,15 @@ -
+ +