Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPE=openid profile email groups
OIDC_GROUPS_CLAIM=groups
OIDC_ADMIN_GROUPS=Admin,Owner,Steering Committee
OIDC_ADMIN_GROUPS=authentik Admins
OIDC_CALLBACK_PATH=/auth/callback
# Optional external base URL used to build redirect_uri (defaults to request base URL)
OIDC_REDIRECT_BASE_URL=
Expand All @@ -78,9 +78,12 @@ DASHBOARD_PUBLIC_BASE_URL=

# Discord admin link checks (DB-first, Discord API fallback)
DISCORD_ADMIN_GUILD_ID=
DISCORD_ADMIN_ROLES=Admin,Owner,Steering Committee
DISCORD_ADMIN_ROLES=Admin,Owner
DISCORD_API_TIMEOUT_SECONDS=8.0
DISCORD_LINK_TTL_SECONDS=600
# Temporary bootstrap switch: when false, Discord deep-link logins do not require
# an OIDC roundtrip and skip OIDC-based admin-group and email-to-Discord-link checks.
DISCORD_LINK_REQUIRE_OIDC_IDENTITY_CHECKS=true

# Worker / consumer (optional defaults)
WORKER_NAME=worker
Expand Down
7 changes: 7 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ curl -X GET "http://localhost:8090/jobs/<job_id>" \
- `GET /auth/discord/link/{token}`: Resolve Discord deep link into authenticated dashboard redirect.
- Auth flows emit best-effort human audit events (`auth.login`, `auth.logout`) under source `admin_dashboard`.

Discord deep-link identity policy:

- `DISCORD_ADMIN_ROLES` controls who can mint/use Discord deep links (`Admin,Owner` recommended).
- `OIDC_ADMIN_GROUPS` controls normal OIDC dashboard admin membership (`authentik Admins` recommended).
- `DISCORD_LINK_REQUIRE_OIDC_IDENTITY_CHECKS=true` (default): Discord deep links also require OIDC admin group + OIDC email linked to Discord admin identity.
- `DISCORD_LINK_REQUIRE_OIDC_IDENTITY_CHECKS=false`: Discord deep links create a Discord-backed admin session directly after re-validating active CRM membership + Discord admin role, without forcing an OIDC roundtrip.

### Known handler wiring expectation

- `/jobs/{job_id}/rerun` replays the source job’s stored call arguments; rerunnable job types must only include callables that are also registered for worker execution.
Expand Down
192 changes: 136 additions & 56 deletions apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,13 @@ async def _current_session(request: Request) -> tuple[str | None, AuthSession |
return session_id, session


def _session_actor_provider(session: AuthSession) -> ActorProvider:
raw_provider = session.actor_provider.strip().lower()
if raw_provider == ActorProvider.DISCORD.value:
return ActorProvider.DISCORD
return ActorProvider.ADMIN_SSO


def _set_session_cookie(
response: JSONResponse | RedirectResponse, session_id: str
) -> None:
Expand Down Expand Up @@ -1269,6 +1276,9 @@ async def auth_callback_handler(
display_name = None

audit_actor_subject = (email or str(claims.get("sub", "")).strip()).strip()
enforce_discord_link_identity_checks = (
settings.discord_link_require_oidc_identity_checks
)

if pending.discord_link_token:
grant = await store.get_discord_link(pending.discord_link_token)
Expand All @@ -1283,58 +1293,64 @@ async def auth_callback_handler(
)
return JSONResponse({"error": "link_not_found"}, status_code=404)

if not is_admin:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={"reason": "admin_group_required", "groups": groups},
correlation_id=state,
)
return JSONResponse(
{"error": "forbidden", "detail": "admin_group_required"},
status_code=403,
)
if enforce_discord_link_identity_checks:
if not is_admin:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={"reason": "admin_group_required", "groups": groups},
correlation_id=state,
)
return JSONResponse(
{"error": "forbidden", "detail": "admin_group_required"},
status_code=403,
)

if not email:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={"reason": "email_claim_required"},
correlation_id=state,
)
return JSONResponse(
{"error": "forbidden", "detail": "email_claim_required"},
status_code=403,
)
if not email:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={"reason": "email_claim_required"},
correlation_id=state,
)
return JSONResponse(
{"error": "forbidden", "detail": "email_claim_required"},
status_code=403,
)

verifier = _discord_admin_verifier_from_app(request.app)
linked = await verifier.is_admin_email_for_discord_user(
email=email,
discord_user_id=grant.discord_user_id,
)
if not linked:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={
"reason": "oidc_user_not_linked_to_discord_admin",
"discord_user_id": grant.discord_user_id,
},
correlation_id=state,
)
return JSONResponse(
{
"error": "forbidden",
"detail": "oidc_user_not_linked_to_discord_admin",
},
status_code=403,
verifier = _discord_admin_verifier_from_app(request.app)
linked = await verifier.is_admin_email_for_discord_user(
email=email,
discord_user_id=grant.discord_user_id,
http_client=http_client,
)
if not linked:
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.DENIED,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={
"reason": "oidc_user_not_linked_to_discord_admin",
"discord_user_id": grant.discord_user_id,
},
correlation_id=state,
)
return JSONResponse(
{
"error": "forbidden",
"detail": "oidc_user_not_linked_to_discord_admin",
},
status_code=403,
)
else:
# Discord deep links are already restricted to Discord admin users.
# In bootstrap mode, skip OIDC group/email-link checks for this path.
is_admin = True

await store.delete_discord_link(pending.discord_link_token)

Expand All @@ -1357,6 +1373,7 @@ async def auth_callback_handler(
is_admin=is_admin,
id_token=id_token,
expires_at=expires_at,
actor_provider=ActorProvider.ADMIN_SSO.value,
),
ttl_seconds=settings.auth_session_ttl_seconds,
)
Expand All @@ -1367,16 +1384,22 @@ async def auth_callback_handler(
)
response = RedirectResponse(url=redirect_to, status_code=302)
_set_session_cookie(response, session_id)
login_audit_metadata: dict[str, Any] = {
"is_admin": is_admin,
"groups": groups,
"via_discord_link": bool(pending.discord_link_token),
}
if pending.discord_link_token:
login_audit_metadata["discord_link_identity_checks_enforced"] = (
enforce_discord_link_identity_checks
)

await _write_auth_audit_event(
action="auth.login",
result=AuditResult.SUCCESS,
actor_subject=audit_actor_subject,
actor_display_name=display_name,
metadata={
"is_admin": is_admin,
"groups": groups,
"via_discord_link": bool(pending.discord_link_token),
},
metadata=login_audit_metadata,
resource_id=session_id,
correlation_id=state,
)
Expand All @@ -1399,6 +1422,7 @@ async def auth_me_handler(request: Request) -> JSONResponse:
"groups": session.groups,
"is_admin": session.is_admin,
"expires_at": session.expires_at,
"actor_provider": session.actor_provider,
}
)

Expand All @@ -1411,11 +1435,16 @@ async def auth_logout_handler(request: Request) -> JSONResponse:
await store.delete_session(session_id)

if session is not None:
actor_provider = _session_actor_provider(session)
actor_subject = session.email or session.subject
if actor_provider == ActorProvider.DISCORD:
actor_subject = session.subject
await _write_auth_audit_event(
action="auth.logout",
result=AuditResult.SUCCESS,
actor_subject=(session.email or session.subject),
actor_subject=actor_subject,
actor_display_name=session.display_name,
actor_provider=actor_provider,
metadata={"is_admin": session.is_admin},
resource_id=session_id,
)
Expand Down Expand Up @@ -1520,7 +1549,7 @@ async def auth_discord_link_redirect_handler(
request: Request,
token: str,
) -> JSONResponse | RedirectResponse:
"""Handle one-time Discord deep link and jump into OIDC login flow."""
"""Handle one-time Discord deep link and create or resume an admin session."""
store = _auth_store_from_app(request.app)
if store is None:
return JSONResponse({"error": "auth_not_ready"}, status_code=503)
Expand All @@ -1530,6 +1559,56 @@ async def auth_discord_link_redirect_handler(
return JSONResponse({"error": "link_not_found"}, status_code=404)

_, session = await _current_session(request)
if not settings.discord_link_require_oidc_identity_checks:
verifier = _discord_admin_verifier_from_app(request.app)
http_client = _http_client_from_app(request.app)
identity = await verifier.resolve_admin_identity(
discord_user_id=grant.discord_user_id,
http_client=http_client,
)
if identity is None:
return JSONResponse(
{"error": "forbidden", "detail": "discord_user_not_admin"},
status_code=403,
)

session_id = secrets.token_urlsafe(32)
expires_at = int(time.time()) + max(1, settings.auth_session_ttl_seconds)
await store.save_session(
session_id=session_id,
payload=AuthSession(
subject=grant.discord_user_id,
email=identity.email,
display_name=identity.display_name,
groups=["discord_admin"],
is_admin=True,
id_token="",
expires_at=expires_at,
actor_provider=ActorProvider.DISCORD.value,
),
ttl_seconds=settings.auth_session_ttl_seconds,
)
await store.delete_discord_link(token)

response = RedirectResponse(url=grant.next_path, status_code=302)
_set_session_cookie(response, session_id)
await _write_auth_audit_event(
action="auth.login",
result=AuditResult.SUCCESS,
actor_subject=grant.discord_user_id,
actor_display_name=identity.display_name,
actor_provider=ActorProvider.DISCORD,
metadata={
"is_admin": True,
"groups": ["discord_admin"],
"via_discord_link": True,
"discord_link_identity_checks_enforced": False,
},
resource_id=session_id,
correlation_id=token,
)
return response

if session is not None:
if not session.is_admin:
return JSONResponse(
Expand All @@ -1547,6 +1626,7 @@ async def auth_discord_link_redirect_handler(
linked = await verifier.is_admin_email_for_discord_user(
email=session.email,
discord_user_id=grant.discord_user_id,
http_client=_http_client_from_app(request.app),
)
if not linked:
return JSONResponse(
Expand Down
Loading