436 lines
16 KiB
Python
436 lines
16 KiB
Python
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"} |