- Add @RELATION/@RATIONALE/@REJECTED headers to agent modules - Add GRACE contracts to backend services, models, schemas - Update build.sh with single-image build commands (build:backend|frontend|agent) - Update docker entrypoint: openssl rsa → openssl pkey (alg-agnostic) - Add @RATIONALE to backend core modules (cot_logger, config_manager, ssl)
153 lines
7.1 KiB
Python
153 lines
7.1 KiB
Python
# #region auth_service [C:5] [TYPE Module] [SEMANTICS sqlalchemy, auth, credential, session, jwt]
|
|
# @defgroup Services Module group.
|
|
# @BRIEF Orchestrates credential authentication and ADFS JIT user provisioning.
|
|
# @LAYER Domain
|
|
# @RELATION DEPENDS_ON -> [AuthRepository]
|
|
# @RELATION DEPENDS_ON -> [verify_password]
|
|
# @RELATION DEPENDS_ON -> [Auth.Jwt.CreateAccessToken]
|
|
# @RELATION DEPENDS_ON -> [User]
|
|
# @RELATION DEPENDS_ON -> [Role]
|
|
# @INVARIANT Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
|
|
# @PRE Core auth models and security utilities available.
|
|
# @POST User identity verified and session tokens issued according to role scopes.
|
|
# @SIDE_EFFECT Writes last login timestamps and JIT-provisions external users.
|
|
# @DATA_CONTRACT [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
|
|
# @RATIONALE Thin service layer between API routes and AuthRepository — encapsulates credential
|
|
# verification (password hashing), ADFS JIT provisioning, and session creation into
|
|
# a single orchestration class. This keeps route handlers free of auth business logic.
|
|
# @REJECTED Putting credential verification logic directly in API route handlers was rejected —
|
|
# it would be duplicated across local login, ADFS callback, and API-key auth paths.
|
|
# Authentication was kept separate from authorization (permission checks in middleware)
|
|
# to allow independent testing of each concern.
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..core.auth.jwt import create_access_token
|
|
from ..core.auth.logger import log_security_event
|
|
from ..core.auth.repository import AuthRepository
|
|
from ..core.auth.security import verify_password
|
|
from ..core.logger import belief_scope
|
|
from ..models.auth import User
|
|
|
|
|
|
# #region AuthService [C:3] [TYPE Class]
|
|
# @defgroup Services Module group.
|
|
# @BRIEF Provides high-level authentication services.
|
|
# @RELATION DEPENDS_ON -> [AuthRepository]
|
|
# @RELATION DEPENDS_ON -> [User]
|
|
# @RELATION DEPENDS_ON -> [Role]
|
|
class AuthService:
|
|
# region AuthService_init [TYPE Function]
|
|
# @PURPOSE: Initializes the authentication service with repository access over an active DB session.
|
|
# @PRE db is a valid SQLAlchemy Session instance bound to the auth persistence context.
|
|
# @POST self.repo is initialized and ready for auth user/role CRUD operations.
|
|
# @SIDE_EFFECT Allocates AuthRepository and binds it to the provided Session.
|
|
# @DATA_CONTRACT Input(Session) -> Model(AuthRepository)
|
|
# @PARAM db (Session) - SQLAlchemy session.
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.repo = AuthRepository(db)
|
|
|
|
# endregion AuthService_init
|
|
|
|
# region AuthService.authenticate_user [TYPE Function]
|
|
# @PURPOSE: Validates credentials and account state for local username/password authentication.
|
|
# @PRE username and password are non-empty credential inputs.
|
|
# @POST Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
|
|
# @SIDE_EFFECT Persists last_login update for successful authentications via repository.
|
|
# @DATA_CONTRACT Input(str username, str password) -> Output(User | None)
|
|
# @RELATION DEPENDS_ON ->[AuthRepository]
|
|
# @RELATION CALLS ->[verify_password]
|
|
# @RELATION DEPENDS_ON ->[User]
|
|
# @PARAM username (str) - The username.
|
|
# @PARAM password (str) - The plain password.
|
|
# @RETURN Optional[User] - The authenticated user or None.
|
|
def authenticate_user(self, username: str, password: str) -> User | None:
|
|
with belief_scope("auth.authenticate_user"):
|
|
user = self.repo.get_user_by_username(username)
|
|
if not user or not user.is_active:
|
|
return None
|
|
|
|
if not verify_password(password, user.password_hash):
|
|
return None
|
|
|
|
# Update last login
|
|
user.last_login = datetime.now(UTC)
|
|
self.db.commit()
|
|
self.db.refresh(user)
|
|
|
|
return user
|
|
|
|
# endregion AuthService.authenticate_user
|
|
|
|
# region AuthService.create_session [TYPE Function]
|
|
# @PURPOSE: Issues an access token payload for an already authenticated user.
|
|
# @PRE user is a valid User entity containing username and iterable roles with role.name values.
|
|
# @POST Returns session dict with non-empty access_token and token_type='bearer'.
|
|
# @SIDE_EFFECT Generates signed JWT via auth JWT provider.
|
|
# @DATA_CONTRACT Input(User) -> Output(Dict[str, str]{access_token, token_type})
|
|
# @RELATION CALLS -> [Auth.Jwt.CreateAccessToken]
|
|
# @RELATION DEPENDS_ON ->[User]
|
|
# @RELATION DEPENDS_ON ->[Role]
|
|
# @PARAM user (User) - The authenticated user.
|
|
# @RETURN Dict[str, str] - Session data.
|
|
def create_session(self, user: User) -> dict[str, str]:
|
|
with belief_scope("auth.create_session"):
|
|
roles = [role.name for role in user.roles]
|
|
access_token = create_access_token(
|
|
data={"sub": user.username, "scopes": roles}
|
|
)
|
|
return {"access_token": access_token, "token_type": "bearer"}
|
|
|
|
# endregion AuthService.create_session
|
|
|
|
# region AuthService.provision_adfs_user [TYPE Function]
|
|
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
|
|
# @PRE user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
|
|
# @POST Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
|
|
# @SIDE_EFFECT May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
|
|
# @DATA_CONTRACT Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
|
|
# @RELATION DEPENDS_ON ->[AuthRepository]
|
|
# @RELATION DEPENDS_ON ->[User]
|
|
# @RELATION DEPENDS_ON ->[Role]
|
|
# @PARAM user_info (Dict[str, Any]) - Claims from ADFS token.
|
|
# @RETURN User - The provisioned user.
|
|
def provision_adfs_user(self, user_info: dict[str, Any]) -> User:
|
|
with belief_scope("auth.provision_adfs_user"):
|
|
username = user_info.get("upn") or user_info.get("email")
|
|
email = user_info.get("email")
|
|
groups = user_info.get("groups", [])
|
|
|
|
user = self.repo.get_user_by_username(username)
|
|
if not user:
|
|
user = User(
|
|
username=username,
|
|
email=email,
|
|
full_name=user_info.get("name"),
|
|
auth_source="ADFS",
|
|
is_active=True,
|
|
is_ad_user=True,
|
|
)
|
|
self.db.add(user)
|
|
log_security_event("USER_PROVISIONED", username, {"source": "ADFS"})
|
|
|
|
# Sync roles from AD groups
|
|
mapped_roles = self.repo.get_roles_by_ad_groups(groups)
|
|
user.roles = mapped_roles
|
|
|
|
user.last_login = datetime.now(UTC)
|
|
self.db.commit()
|
|
self.db.refresh(user)
|
|
|
|
return user
|
|
|
|
# endregion AuthService.provision_adfs_user
|
|
|
|
|
|
# #endregion AuthService
|
|
|
|
# #endregion auth_service
|