Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 67 additions & 14 deletions services/flask-backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
from __future__ import annotations

import hashlib
from datetime import datetime
import logging
from datetime import datetime, timezone
from functools import wraps
from typing import Any, Callable

logger = logging.getLogger(__name__)

from pydantic import ValidationError
from quart import Blueprint, current_app, g, jsonify, request

Expand Down Expand Up @@ -181,14 +184,15 @@ def create_access_token(

import jwt

expires = datetime.utcnow() + current_app.config["JWT_ACCESS_TOKEN_EXPIRES"]
now = datetime.now(timezone.utc)
expires = now + current_app.config["JWT_ACCESS_TOKEN_EXPIRES"]
payload = {
"sub": str(user_id),
"role": role,
"type": "access",
"jti": str(uuid.uuid4()),
"exp": expires,
"iat": datetime.utcnow(),
"iat": now,
}
if tenant_id is not None:
payload["tenant_id"] = tenant_id
Expand All @@ -204,12 +208,13 @@ def create_refresh_token(user_id: int) -> tuple[str, datetime]:

import jwt

expires = datetime.utcnow() + current_app.config["JWT_REFRESH_TOKEN_EXPIRES"]
now = datetime.now(timezone.utc)
expires = now + current_app.config["JWT_REFRESH_TOKEN_EXPIRES"]
payload = {
"sub": str(user_id),
"type": "refresh",
"exp": expires,
"iat": datetime.utcnow(),
"iat": now,
"jti": str(uuid.uuid4()),
}
token = jwt.encode(payload, current_app.config["JWT_SECRET_KEY"], algorithm="HS256")
Expand Down Expand Up @@ -278,7 +283,7 @@ async def login():

def _resolve_tenant(uid):
db = _get_db()
from .rbac import _define_rbac_tables, get_user_scopes
from .rbac import _define_rbac_tables

_define_rbac_tables(db)
if "tenants" in db.tables and "tenant_members" in db.tables:
Expand All @@ -300,17 +305,17 @@ def _resolve_tenant(uid):
return None, None

tenant_id, tenant_slug = await run_sync(_resolve_tenant, user["id"])
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to resolve tenant for user {user['id']}: {e}")

# Get user scopes for token
user_scopes = None
try:
from .rbac import get_user_scopes

user_scopes = await run_sync(get_user_scopes, user["id"], tenant_id)
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to resolve scopes for user {user['id']}: {e}")

# Generate tokens
access_token = create_access_token(
Expand Down Expand Up @@ -405,8 +410,56 @@ async def refresh():
# Revoke old refresh token
await run_sync(revoke_refresh_token, token_hash)

# Generate new tokens
access_token = create_access_token(user["id"], user["role"])
# Resolve user's default tenant (same as login flow)
tenant_id = None
tenant_slug = None
try:
from .models import get_db as _get_db

def _resolve_tenant(uid):
db = _get_db()
from .rbac import _define_rbac_tables

_define_rbac_tables(db)
if "tenants" in db.tables and "tenant_members" in db.tables:
membership = (
db(
(db.tenant_members.user_id == uid)
& (db.tenant_members.tenant_id == db.tenants.id)
)
.select(
db.tenants.id,
db.tenants.slug,
db.tenants.is_default,
orderby=~db.tenants.is_default,
)
.first()
)
if membership:
return membership["tenants.id"], membership["tenants.slug"]
return None, None

tenant_id, tenant_slug = await run_sync(_resolve_tenant, user["id"])
except Exception as e:
logger.warning(f"Failed to resolve tenant for user {user['id']} on refresh: {e}")

# Get user scopes for token
user_scopes = None
try:
from .rbac import get_user_scopes

user_scopes = await run_sync(get_user_scopes, user["id"], tenant_id)
except Exception as e:
logger.warning(f"Failed to resolve scopes for user {user['id']} on refresh: {e}")

# Generate new tokens with tenant and scopes
access_token = create_access_token(
user["id"],
user["role"],
tenant_id=tenant_id,
tenant_slug=tenant_slug,
scopes=user_scopes,
)
new_refresh_token, refresh_expires = await run_sync(
create_refresh_token, user["id"]
)
Expand Down Expand Up @@ -579,8 +632,8 @@ def _add_to_default_tenant(uid):
db.commit()

await run_sync(_add_to_default_tenant, user["id"])
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to add user {user['id']} to default tenant: {e}")

# Build response
user_response = UserResponse(
Expand Down
96 changes: 67 additions & 29 deletions services/flask-backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@

from __future__ import annotations

from datetime import datetime
import logging
from datetime import datetime, timezone
from typing import Optional

logger = logging.getLogger(__name__)

from pydal import DAL, Field
from pydal.validators import IS_EMAIL, IS_IN_SET, IS_NOT_EMPTY
from quart import Quart, g
Expand Down Expand Up @@ -156,15 +159,21 @@ def init_db(app: Quart) -> DAL:
_ensure_default_roles(db)

# Initialize RBAC tables and scopes
from .rbac import init_rbac_tables
from .rbac import init_rbac_tables, _define_rbac_tables
from .oidc import _define_oidc_tables

init_rbac_tables(db)

# Define RBAC and OIDC tables at startup to avoid race conditions
# (no more lazy define_table at request time)
_define_rbac_tables(db)
_define_oidc_tables(db)

# Ensure default tenant exists
_ensure_default_tenant(db)

# Seed default admin user from environment variables
_seed_default_admin(db)
_seed_default_admin(db, app)

# Store db instance in app
app.config["db"] = db
Expand Down Expand Up @@ -566,8 +575,16 @@ def _ensure_default_tenant(db: DAL) -> None:
db.rollback()


def _seed_default_admin(db: DAL) -> None:
"""Create default admin user from env vars if no users exist."""
def _seed_default_admin(db: DAL, app: Quart) -> None:
"""Create default admin user from env vars if no users exist.

In production (RELEASE_MODE), requires explicit DEFAULT_ADMIN_PASSWORD env var.
In development, uses built-in default but logs a warning.

Args:
db: PyDAL database instance
app: Quart application instance
"""
import os
import uuid

Expand All @@ -581,8 +598,35 @@ def _seed_default_admin(db: DAL) -> None:
if user_count > 0:
return

# Check if in production mode
is_production = app.config.get("RELEASE_MODE", False)

admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD", "changeme123")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")

# In production, require explicit password configuration
if is_production:
if not admin_password:
logger.error(
"PRODUCTION: Refusing to seed default admin with built-in password. "
"Set DEFAULT_ADMIN_PASSWORD environment variable explicitly."
)
return
if admin_password == "changeme123":
logger.error(
"PRODUCTION: Refusing to seed default admin with built-in weak password. "
"Set DEFAULT_ADMIN_PASSWORD to a strong password."
)
return
else:
# Development: use built-in default but warn
if not admin_password:
admin_password = "changeme123"
logger.warning(
"DEVELOPMENT: Seeding default admin with built-in weak password. "
"This is only acceptable in development. "
"In production, set DEFAULT_ADMIN_PASSWORD environment variable."
)

try:
existing = db(db.auth_user.email == admin_email).select().first()
Expand All @@ -603,7 +647,9 @@ def _seed_default_admin(db: DAL) -> None:
db.auth_user_roles.insert(user_id=user_id, role_id=admin_role.id)

db.commit()
except Exception:
logger.info(f"Default admin user created: {admin_email}")
except Exception as e:
logger.error(f"Failed to seed default admin: {e}")
db.rollback()


Expand Down Expand Up @@ -705,26 +751,15 @@ def create_user(
db.auth_user_roles.insert(user_id=user_id, role_id=role_row.id)

# Also assign role at global level in new RBAC system
# Define user_role_assignments table if not already defined
if "user_role_assignments" not in db.tables:
db.define_table(
"user_role_assignments",
db.Field("user_id", "reference auth_user"),
db.Field("role_id", "reference auth_role"),
db.Field("scope_level", "string", length=20),
db.Field("scope_id", "integer"),
db.Field("created_at", "datetime"),
migrate=False,
# user_role_assignments table is already defined at startup
if "user_role_assignments" in db.tables:
db.user_role_assignments.insert(
user_id=user_id,
role_id=role_row.id,
scope_level="global",
scope_id=None,
)

# Assign global-level role
db.user_role_assignments.insert(
user_id=user_id,
role_id=role_row.id,
scope_level="global",
scope_id=None,
)

db.commit()
return get_user_by_id(user_id)

Expand Down Expand Up @@ -847,11 +882,12 @@ def is_refresh_token_valid(token_hash: str) -> bool:
db = get_db()
if "refresh_tokens" not in db.tables:
return False
now = datetime.now(timezone.utc)
token = (
db(
(db.refresh_tokens.token_hash == token_hash)
& (db.refresh_tokens.revoked == False) # noqa: E712
& (db.refresh_tokens.expires_at > datetime.utcnow())
& (db.refresh_tokens.expires_at > now)
)
.select()
.first()
Expand Down Expand Up @@ -880,11 +916,13 @@ def store_revoked_access_token(user_id: int, jti: str, exp_ts: int) -> None:
db.revoked_access_tokens.insert(
user_id=user_id,
jti=jti,
expires_at=datetime.utcfromtimestamp(exp_ts) if exp_ts else None,
expires_at=datetime.fromtimestamp(exp_ts, tz=timezone.utc)
if exp_ts
else None,
)
db.commit()
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to store revoked access token {jti}: {e}")


def is_access_token_revoked(jti: str) -> bool:
Expand Down
Loading