Files
Aegis/backend/app/models/user.py
T
kitos 504dfc52f5
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
feat(auth): make email the unique login identifier
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.

- User.email is now unique + NOT NULL (migration b067 backfills any
  missing/blank email from username first, so existing rows — notably
  the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
  authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
  create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
  duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
  docker-compose.prod.yml) for the initial admin; falls back to an
  email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
  operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
  skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
  'username' — that's the OAuth2PasswordRequestForm spec, not the value).
2026-07-23 14:39:36 +02:00

62 lines
3.2 KiB
Python

"""SQLAlchemy model for the users table."""
# Import uuid
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
# Import Base from app.database
from app.database import Base
# Define class User
class User(Base):
"""User model for authentication and authorization.
Possible roles:
- admin: Full system access
- red_tech: Red team technician - can create and edit tests
- blue_tech: Blue team technician - can create and edit tests
- red_lead: Red team lead - can validate tests
- blue_lead: Blue team lead - can validate tests
- viewer: Read-only access (default)
"""
# Assign __tablename__ = "users"
__tablename__ = "users"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Internal login identifier — always kept equal to `email` (see below).
# Kept as a separate column (rather than removed) since the JWT `sub`
# claim, audit logs, Jira actor attribution, and SSO provisioning all
# still key off it; changing all of those to read `email` directly
# would be a much larger, riskier refactor for no behavioral gain now
# that the two are always identical.
username = Column(String, unique=True, nullable=False)
# The unique identifier a user logs in with. Every user must have one.
email = Column(String, unique=True, nullable=False, index=True)
# Display name shown everywhere in the UI instead of username (which is
# now just an internal login identifier, auto-set to the user's email).
full_name = Column(String, nullable=True)
# Assign hashed_password = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer")
role = Column(String, nullable=False, default="viewer")
# Other roles this user can switch into (the currently-active role
# lives in `role` above and is what every permission check reads —
# switching just swaps which one is active, never grants both at once).
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign must_change_password = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign last_login = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True)
notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}')
jira_account_id = Column(String(100), nullable=True)
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
tempo_api_token = Column(String(500), nullable=True) # personal Tempo API token