import grp import os import pwd import uuid from datetime import datetime, timedelta, UTC from typing import List import time import jwt from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from pydantic import BaseModel from aare.common.auth_models import BatonRequestStatus, BatonTransferQueue, BatonRequest, BatonStatus from aare.common.models import SessionsStateEnum from aare.daq.config import BeamlineConfig from aare.common.exception_handler import AuthenticationException, UserRightsException, AuthErrorCode if os.environ.get("JWT_AAREDAQ_KEY") is None: raise Exception("JWT_AAREDAQ_KEY environment variable not set, cannot guarantee safe authentication.") SECRET_KEY = os.environ.get("JWT_AAREDAQ_KEY") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 24 * 60 * 7 # 1 week SESSION_EXPIRE_SECONDS = 60 * 10 BATON_REQUEST_TIMEOUT_SECONDS = 30 STAFF_GROUP = "unx-MXgroup" SUPER_USERS = ["e10019", "e11206", "e18147"] oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") class TokenData(BaseModel): sub: str # Username pgroups: List[str] session: int staff: bool = False def create_access_token(token: TokenData): to_encode = token.model_dump() to_encode.update({"exp": datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt def authenticate_from_proxy_header(request: Request) -> str: """ Extract the pre-authenticated username from the trusted Apache proxy header. Only trust this header when the request originates from localhost (127.0.0.1 / ::1), meaning only the Apache proxy running on the same host can supply it. """ client_host = request.client.host if request.client else None if client_host not in ("127.0.0.1", "::1"): raise AuthenticationException( message="X-Remote-User header is only trusted from the localhost proxy", status_code=401, headers={"WWW-Authenticate": "Bearer"}, code=AuthErrorCode.INVALID_TOKEN, ) remote_user = request.headers.get("X-Remote-User") if not remote_user: raise AuthenticationException( message="Missing X-Remote-User header from proxy", status_code=401, headers={"WWW-Authenticate": "Bearer"}, code=AuthErrorCode.INVALID_TOKEN, ) return remote_user def authenticate_user(cfg: BeamlineConfig, username: str) -> str: user_info = pwd.getpwnam(username) groups = os.getgrouplist(user_info.pw_name, user_info.pw_gid) supplementary_groups = [grp.getgrgid(g).gr_name.lower() for g in groups] super_user = username in SUPER_USERS pgroups = [group for group in supplementary_groups if group.startswith('p')] staff = "unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user token = TokenData(sub=username, pgroups=pgroups, staff=staff, session=cfg.generate_session()) return create_access_token(token) def parse_token(token: str = Depends(oauth2_scheme)) -> TokenData: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) token = TokenData(**payload) return token except jwt.PyJWTError as e: raise AuthenticationException( message="Invalid token", status_code=401, headers={"WWW-Authenticate": "Bearer"}, code=AuthErrorCode.INVALID_TOKEN, ) from e def check_jwt_ro(cfg: BeamlineConfig, data: TokenData) -> None: active_pgroup = cfg.pgroup if not data.staff and (active_pgroup is None or active_pgroup not in data.pgroups): raise UserRightsException( message="Not member of a currently active p-group.", status_code=403, code=AuthErrorCode.NOT_IN_ACTIVE_PGROUP, ) def check_jwt_rw(cfg: BeamlineConfig, data: TokenData) -> None: check_jwt_ro(cfg, data) holder = cfg.baton_holder if holder is None or holder.session != data.session: raise UserRightsException( message="You do not hold the baton.", status_code=403, code=AuthErrorCode.NOT_BATON_HOLDER, ) try: cfg.try_extend_active_session(data.session, SESSION_EXPIRE_SECONDS) except Exception as e: # In case something is wrong but you are holder (maybe redis expiry?) cfg.try_set_active_session(data.session, SESSION_EXPIRE_SECONDS) def check_jwt_staff_only(data: TokenData) -> None: if not data.staff: raise UserRightsException( message="Not member of the MX staff.", status_code=403, code=AuthErrorCode.NOT_STAFF, ) def check_jwt_staff(cfg: BeamlineConfig, data: TokenData) -> None: check_jwt_staff_only(data) holder = cfg.baton_holder if holder and holder.session != data.session: # Staff can take over if they don't have it, but they need to use force_current_session # or request_baton (which does staff override). # If they are calling a RW endpoint, they SHOULD already be the holder. pass try: cfg.try_extend_active_session(data.session, SESSION_EXPIRE_SECONDS) except Exception: cfg.try_set_active_session(data.session, SESSION_EXPIRE_SECONDS) def force_current_sesion(cfg: BeamlineConfig, data: TokenData) -> None: cfg.execute_baton_transfer( to_session=data.session, to_username=data.sub, to_is_staff=data.staff, to_pgroup=cfg.pgroup, expiry_sec=SESSION_EXPIRE_SECONDS ) def _finalize_expired_baton_request(cfg: BeamlineConfig, pending: BatonRequest) -> BatonStatus: """ Resolve an expired baton request in one place. Rules: - staff can override immediately if beamline is free - otherwise the transfer is queued - pending request is cleared once it is no longer pending """ requester_is_staff = bool(pending.requester_is_staff) if cfg.can_transfer_baton_now(): cfg.execute_baton_transfer( to_session=pending.requester_session, to_username=pending.requester_username, to_is_staff=requester_is_staff, to_pgroup=cfg.pgroup, expiry_sec=SESSION_EXPIRE_SECONDS ) else: cfg.queued_baton_transfer = BatonTransferQueue( target_session=pending.requester_session, target_username=pending.requester_username, target_is_staff=requester_is_staff, target_pgroup=cfg.pgroup, queued_at=time.time(), reason="timeout_beamline_busy" ) cfg.clear_pending_baton_request() return get_baton_status(cfg, TokenData( sub=pending.requester_username, pgroups=[], session=pending.requester_session, staff=requester_is_staff )) def resolve_baton_timeout_if_needed(cfg: BeamlineConfig) -> BatonStatus | None: """ Check the current pending request and resolve it if expired. Returns the updated BatonStatus when a timeout was processed, else None. """ pending = cfg.pending_baton_request if pending is None or pending.status != BatonRequestStatus.PENDING: return None elapsed = time.time() - pending.created_at if elapsed < pending.timeout_seconds: return None return _finalize_expired_baton_request(cfg, pending) def get_baton_status(cfg: BeamlineConfig, data: TokenData) -> BatonStatus: """ Build baton status scoped to the requesting session. Important: - requester sees you_have_pending_request - holder sees incoming_request - nobody else sees the request as actionable """ holder = cfg.baton_holder pending = cfg.pending_baton_request is_requester = bool( pending and pending.status in (BatonRequestStatus.PENDING, BatonRequestStatus.REFUSED) and pending.requester_session == data.session ) is_holder = bool( pending and pending.status == BatonRequestStatus.PENDING and pending.holder_session == data.session ) # Only expose the pending request object to the two relevant sessions scoped_pending = pending if (is_requester or is_holder) else None return BatonStatus( holder=holder, pending_request=scoped_pending, queued_transfer=cfg.queued_baton_transfer, you_are_holder=bool(holder and holder.session == data.session), you_have_pending_request=is_requester, incoming_request=is_holder, allow_non_staff_request=cfg.allow_non_staff_request_from_staff, ) def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: resolve_baton_timeout_if_needed(cfg) session_state = cfg.session_state(data.session) if session_state == SessionsStateEnum.Vacant: # State: VACANT -> OWNED (B) cfg.execute_baton_transfer( to_session=data.session, to_username=data.sub, to_is_staff=data.staff, to_pgroup=cfg.pgroup, expiry_sec=SESSION_EXPIRE_SECONDS, ) return {"granted": True, "message": "Baton acquired (beamline was vacant)"} if session_state == SessionsStateEnum.OwnedByYou: cfg.try_extend_active_session(data.session, SESSION_EXPIRE_SECONDS) return {"already_holder": True, "message": "You already hold the baton"} if session_state == SessionsStateEnum.PendingYouToElse: # Already requested, waiting for holder response or timeout existing_request = cfg.pending_baton_request if existing_request: elapsed = time.time() - existing_request.created_at remaining = existing_request.timeout_seconds - elapsed return { "pending": True, "existing": True, "remaining_seconds": max(0, remaining), "message": f"Request already pending ({remaining:.0f}s remaining)", } holder = cfg.baton_holder # Handle Staff override if data.staff: if not cfg.can_transfer_baton_now(): # State: OWNED (A) -> PENDING (A -> B, B=staff) -> Queue -> OWNED (B) cfg.queued_baton_transfer = BatonTransferQueue( target_session=data.session, target_username=data.sub, target_is_staff=data.staff, target_pgroup=cfg.pgroup, queued_at=time.time(), reason="beamline_busy_staff_override", ) return { "queued": True, "message": "Staff override queued - will transfer when beamline is available", } # State: OWNED (A) -> OWNED (B, B=staff) cfg.execute_baton_transfer( to_session=data.session, to_username=data.sub, to_is_staff=data.staff, to_pgroup=cfg.pgroup, expiry_sec=SESSION_EXPIRE_SECONDS, ) return {"granted": True, "override": True, "message": "Staff override - baton acquired"} # Policy check: non-staff requesting from staff if holder and holder.is_staff and not data.staff and not cfg.allow_non_staff_request_from_staff: return { "error": True, "message": "Requesting baton from staff is disabled by backend policy.", } # State: OWNED (A) -> PENDING (A -> B) existing_request = cfg.pending_baton_request if existing_request and existing_request.status == BatonRequestStatus.PENDING: # Someone else already has a request pending return { "error": True, "message": f"Another user ({existing_request.requester_username}) already has a pending request", } # Beamline busy notification is_busy = not cfg.can_transfer_baton_now() request = BatonRequest( request_id=str(uuid.uuid4()), requester_username=data.sub, requester_session=data.session, requester_is_staff=data.staff, holder_username=holder.username if holder else None, holder_session=holder.session if holder else None, created_at=time.time(), timeout_seconds=BATON_REQUEST_TIMEOUT_SECONDS, status=BatonRequestStatus.PENDING, ) cfg.set_pending_baton_request(request, timeout_sec=BATON_REQUEST_TIMEOUT_SECONDS) msg = f"Request sent to {holder.username if holder else 'current holder'}" if is_busy: msg += " (Note: beamline is currently busy, transfer will be queued if accepted)" return { "pending": True, "request_id": request.request_id, "timeout_seconds": BATON_REQUEST_TIMEOUT_SECONDS, "message": msg, "beamline_busy": is_busy, } def respond_to_baton_request(cfg: BeamlineConfig, data: TokenData, accept: bool) -> dict: """ Current baton holder responds to a pending request. """ resolve_baton_timeout_if_needed(cfg) holder = cfg.baton_holder if holder is None or holder.session != data.session: return {"error": True, "message": "You are not the current baton holder"} pending = cfg.pending_baton_request if pending is None or pending.status != BatonRequestStatus.PENDING: return {"error": True, "message": "No pending request to respond to"} if accept: # State: PENDING (A -> B) -> OWNED (B) if cfg.can_transfer_baton_now(): cfg.execute_baton_transfer( to_session=pending.requester_session, to_username=pending.requester_username, to_is_staff=pending.requester_is_staff, to_pgroup=cfg.pgroup, expiry_sec=SESSION_EXPIRE_SECONDS ) return {"accepted": True, "transferred": True, "message": "Baton transferred"} else: # Beamline busy, queue the transfer cfg.queued_baton_transfer = BatonTransferQueue( target_session=pending.requester_session, target_username=pending.requester_username, target_is_staff=pending.requester_is_staff, target_pgroup=cfg.pgroup, queued_at=time.time(), reason="accepted_beamline_busy" ) cfg.clear_pending_baton_request() return { "accepted": True, "queued": True, "message": "Request accepted. Baton will be transferred as soon as beamline is available.", } else: # State: PENDING (A -> B) -> OWNED (A) pending.status = BatonRequestStatus.REFUSED cfg.set_pending_baton_request(pending, timeout_sec=10) return {"refused": True, "message": "Baton request refused"} def release_baton(cfg: BeamlineConfig, data: TokenData) -> dict: """ Voluntarily release the baton (set session to free). """ resolve_baton_timeout_if_needed(cfg) holder = cfg.baton_holder if holder is None: return {"released": True, "message": "Baton was already vacant"} if holder.session != data.session: return {"info": True, "message": "You don't hold the baton"} cfg.end_active_session(data.session) cfg.baton_holder = None cfg.clear_pending_baton_request() return {"released": True, "message": "Baton released - beamline is now vacant"} def cancel_baton_request(cfg: BeamlineConfig, data: TokenData) -> dict: """ Cancel your own pending baton request. """ resolve_baton_timeout_if_needed(cfg) pending = cfg.pending_baton_request if pending is None: return {"error": True, "message": "No pending request to cancel"} if pending.requester_session != data.session: return {"error": True, "message": "You can only cancel your own request"} cfg.clear_pending_baton_request() return {"cancelled": True, "message": "Request cancelled"}