feat: add role-based admin support for multiple admin users

- Add Role model and user_roles association table (many-to-many)
- Add password_hash column to User model (nullable, for future bcrypt)
- Keep is_admin boolean for backward compatibility
- Add is_admin_effective property: true if legacy is_admin OR 'admin' role
- Update auth dependency (get_current_admin_user) to use is_admin_effective
- Update bootstrap login to auto-create 'admin' role and assign on login
- Update Google OAuth login to use is_admin_effective for token creation
- Add migrate_roles.py: idempotent migration script to backfill roles
- Add unit tests for Role, User, association table, and is_admin_effective
This commit is contained in:
Hermes Agent
2026-07-30 04:54:30 +00:00
parent a36fd92ad4
commit 0eca079f28
5 changed files with 220 additions and 8 deletions
+49 -2
View File
@@ -1,11 +1,47 @@
"""User, Role, and UserRole models — supports multiple admin roles."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, relationship
from app.models import Base
# ── Many-to-many: users ↔ roles ──────────────────────────────────────────────
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)
# ── Role ─────────────────────────────────────────────────────────────────────
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False)
description = Column(Text, nullable=True)
users = relationship("User", secondary=user_roles, back_populates="roles")
# ── User ─────────────────────────────────────────────────────────────────────
class User(Base):
__tablename__ = "users"
@@ -14,5 +50,16 @@ class User(Base):
display_name = Column(String(100), nullable=False)
avatar_url = Column(String(500), nullable=True)
auth_provider = Column(String(20), nullable=False, default="google")
password_hash = Column(String(255), nullable=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Role-based admin (many-to-many)
roles = relationship("Role", secondary=user_roles, back_populates="users")
@property
def is_admin_effective(self) -> bool:
"""True when is_admin=True (legacy) OR the user has an 'admin' role."""
if self.is_admin:
return True
return any(role.name == "admin" for role in self.roles)