feat(jira): add editable jira_email field per user
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Users can now set a separate Atlassian email for Jira authentication
in Settings → Profile → Jira Integration. Falls back to the Aegis
account email when not set, so existing setups are unaffected.

- Migration b043: adds jira_email column to users table
- User model/schema: expose jira_email read/write
- jira_service: _effective_jira_email() uses jira_email ?? email
- Frontend: replaces read-only email display with editable input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-26 16:40:46 +02:00
parent f316a249cc
commit 217c4c88b2
7 changed files with 85 additions and 19 deletions

View File

@@ -0,0 +1,28 @@
"""Add jira_email to users table.
Allows each user to specify a separate email for Jira authentication,
independent of their Aegis account email.
Revision ID: b043
Revises: b042
Create Date: 2026-05-26
"""
from alembic import op
import sqlalchemy as sa
revision = "b043"
down_revision = "b042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("jira_email", sa.String(255), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "jira_email")

View File

@@ -31,3 +31,4 @@ class User(Base):
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)

View File

@@ -40,8 +40,8 @@ def update_my_preferences(
"""
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "jira_api_token":
# Empty string means "clear token"
if field in ("jira_api_token", "jira_email"):
# Empty string means "clear the value"
setattr(current_user, field, value if value else None)
else:
setattr(current_user, field, value)

View File

@@ -129,6 +129,9 @@ class UserPreferencesUpdate(BaseModel):
# Personal Jira API token (Atlassian token) — write-only, stored encrypted at rest.
# Set to empty string "" to clear the token.
jira_api_token: str | None = None
# Atlassian email for Jira auth — overrides account email.
# Set to empty string "" to clear (falls back to account email).
jira_email: str | None = None
class UserOut(BaseModel):
@@ -144,6 +147,7 @@ class UserOut(BaseModel):
last_login: datetime | None = None
notification_preferences: dict | None = None
jira_account_id: str | None = None
jira_email: str | None = None
# Never return the raw token — just indicate whether it is configured.
jira_token_set: bool = False

View File

@@ -2,10 +2,11 @@
Authentication model
--------------------
Each Aegis user authenticates to Jira with their own corporate email
(``user.email``) and their personal Atlassian API token
(``user.jira_api_token``). This way every Jira action is traceable to a
real person rather than a shared service account.
Each Aegis user authenticates to Jira with their own Atlassian email and
personal API token. The email used is ``user.jira_email`` when set, falling
back to ``user.email`` (the Aegis account email). This lets users specify a
separate corporate Atlassian email without changing their Aegis login.
The token is stored in ``user.jira_api_token``.
Admin configuration
-------------------
@@ -107,9 +108,15 @@ def upsert_jira_config(db: Session, key: str, value: str) -> None:
# ---------------------------------------------------------------------------
def _effective_jira_email(user: User) -> Optional[str]:
"""Return the email to use for Jira auth: jira_email if set, otherwise email."""
return getattr(user, "jira_email", None) or user.email
def get_user_jira_client(user: User, db: Session):
"""Build an Atlassian Jira client authenticated as *user*.
Uses ``user.jira_email`` when set, otherwise falls back to ``user.email``.
Raises ``InvalidOperationError`` when configuration is incomplete so
callers can surface meaningful error messages.
"""
@@ -120,23 +127,24 @@ def get_user_jira_client(user: User, db: Session):
"System Settings → Jira Configuration."
)
if not user.email:
auth_email = _effective_jira_email(user)
if not auth_email:
raise InvalidOperationError(
"Your account has no email address. Set one in your profile before "
"using the Jira integration."
"No email configured for Jira authentication. "
"Set a Jira email in Settings → Profile → Jira Integration."
)
if not user.jira_api_token:
raise InvalidOperationError(
"You have not configured a Jira API token. "
"Go to Settings → Integrations and add your personal Atlassian token."
"Go to Settings → Profile → Jira Integration and add your personal Atlassian token."
)
from atlassian import Jira
return Jira(
url=jira_url,
username=user.email,
username=auth_email,
password=user.jira_api_token,
cloud=True,
)
@@ -144,7 +152,7 @@ def get_user_jira_client(user: User, db: Session):
def has_jira_configured(user: User, db: Session) -> bool:
"""Return True if *user* has everything needed to call Jira."""
return bool(get_jira_url(db) and user.email and user.jira_api_token)
return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token)
# ---------------------------------------------------------------------------