Передаем на тест

This commit is contained in:
2026-01-27 16:32:08 +03:00
parent cc244c2d86
commit d3c3a80ed2
42 changed files with 2836 additions and 140 deletions

View File

@@ -1,59 +1,108 @@
# [DEF:AuthModule:Module]
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
# @LAYER: UI (API)
# @RELATION: Used by API routers to protect endpoints that require authentication.
# [DEF:backend.src.api.auth:Module]
#
# @SEMANTICS: api, auth, routes, login, logout
# @PURPOSE: Authentication API endpoints.
# @LAYER: API
# @RELATION: USES -> backend.src.services.auth_service.AuthService
# @RELATION: USES -> backend.src.core.database.get_auth_db
#
# @INVARIANT: All auth endpoints must return consistent error codes.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..core.database import get_auth_db
from ..services.auth_service import AuthService
from ..schemas.auth import Token, User as UserSchema
from ..dependencies import get_current_user
from ..core.auth.oauth import oauth
from ..core.auth.logger import log_security_event
from ..core.logger import belief_scope
import starlette.requests
# [/SECTION]
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
# Create an in-memory .env file
from io import StringIO
config_data = StringIO("""
ADFS_CLIENT_ID=your-client-id
ADFS_CLIENT_SECRET=your-client-secret
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
""")
config = Config(config_data)
oauth = OAuth(config)
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [/DEF:router:Variable]
oauth.register(
name='adfs',
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
client_kwargs={'scope': 'openid profile email'}
)
# [DEF:login_for_access_token:Function]
# @PURPOSE: Authenticates a user and returns a JWT access token.
# @PRE: form_data contains username and password.
# @POST: Returns a Token object on success.
# @THROW: HTTPException 401 if authentication fails.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.login"):
auth_service = AuthService(db)
user = auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
return auth_service.create_session(user)
# [/DEF:login_for_access_token:Function]
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
)
# [DEF:read_users_me:Function]
# @PURPOSE: Retrieves the profile of the currently authenticated user.
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile.
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
return current_user
# [/DEF:read_users_me:Function]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency to get the current user from the ADFS token.
# @PARAM: token (str) - The OAuth2 bearer token.
# @PRE: token should be provided via Authorization header.
# @POST: Returns user details if authenticated, else raises 401.
# @RETURN: Dict[str, str] - User information.
async def get_current_user(token: str = Depends(oauth2_scheme)):
"""
Dependency to get the current user from the ADFS token.
This is a placeholder and needs to be fully implemented.
"""
# In a real implementation, you would:
# 1. Validate the token with ADFS.
# 2. Fetch user information.
# 3. Create a user object.
# For now, we'll just check if a token exists.
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# A real implementation would return a user object.
return {"placeholder_user": "user@example.com"}
# [/DEF:get_current_user:Function]
# [/DEF:AuthModule:Module]
# [DEF:logout:Function]
# @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PRE: Valid JWT token provided.
# @POST: Returns success message.
@router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"):
log_security_event("LOGOUT", current_user.username)
# In a stateless JWT setup, client-side token deletion is primary.
# Server-side revocation (blacklisting) can be added here if needed.
return {"message": "Successfully logged out"}
# [/DEF:logout:Function]
# [DEF:login_adfs:Function]
# @PURPOSE: Initiates the ADFS OIDC login flow.
# @POST: Redirects the user to ADFS.
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"):
redirect_uri = request.url_for('auth_callback_adfs')
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function]
# [DEF:auth_callback_adfs:Function]
# @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token.
@router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"):
token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo')
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module]

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin

View File

@@ -0,0 +1,305 @@
# [DEF:backend.src.api.routes.admin:Module]
#
# @SEMANTICS: api, admin, users, roles, permissions
# @PURPOSE: Admin API endpoints for user and role management.
# @LAYER: API
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.dependencies.has_permission
#
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
# [SECTION: IMPORTS]
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_auth_db
from ...core.auth.repository import AuthRepository
from ...core.auth.security import get_password_hash
from ...schemas.auth import (
User as UserSchema, UserCreate, UserUpdate,
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
ADGroupMappingSchema, ADGroupMappingCreate
)
from ...models.auth import User, Role, Permission, ADGroupMapping
from ...dependencies import has_permission, get_current_user
from ...core.logger import belief_scope
# [/SECTION]
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for admin routes.
router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable]
# [DEF:list_users:Function]
# @PURPOSE: Lists all registered users.
# @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "READ"))
):
with belief_scope("api.admin.list_users"):
users = db.query(User).all()
return users
# [/DEF:list_users:Function]
# [DEF:create_user:Function]
# @PURPOSE: Creates a new local user.
# @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database.
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.create_user"):
repo = AuthRepository(db)
if repo.get_user_by_username(user_in.username):
raise HTTPException(status_code=400, detail="Username already exists")
new_user = User(
username=user_in.username,
email=user_in.email,
password_hash=get_password_hash(user_in.password),
auth_source="LOCAL",
is_active=user_in.is_active
)
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
new_user.roles.append(role)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
# [/DEF:create_user:Function]
# [DEF:update_user:Function]
# @PURPOSE: Updates an existing user.
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
user_in: UserUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.update_user"):
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.email is not None:
user.email = user_in.email
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
user.password_hash = get_password_hash(user_in.password)
if user_in.roles is not None:
user.roles = []
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
user.roles.append(role)
db.commit()
db.refresh(user)
return user
# [/DEF:update_user:Function]
# [DEF:delete_user:Function]
# @PURPOSE: Deletes a user.
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.delete_user"):
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return None
# [/DEF:delete_user:Function]
# [DEF:list_roles:Function]
# @PURPOSE: Lists all available roles.
# @RETURN: List[RoleSchema] - List of roles.
# @RELATION: CALLS -> backend.src.models.auth.Role
@router.get("/roles", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_roles"):
return db.query(Role).all()
# [/DEF:list_roles:Function]
# [DEF:create_role:Function]
# @PURPOSE: Creates a new system role with associated permissions.
# @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db.
# @PARAM: role_in (RoleCreate) - New role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The created role.
# @SIDE_EFFECT: Commits new role and associations to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
async def create_role(
role_in: RoleCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.create_role"):
if db.query(Role).filter(Role.name == role_in.name).first():
raise HTTPException(status_code=400, detail="Role already exists")
new_role = Role(name=role_in.name, description=role_in.description)
repo = AuthRepository(db)
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
new_role.permissions.append(perm)
db.add(new_role)
db.commit()
db.refresh(new_role)
return new_role
# [/DEF:create_role:Function]
# [DEF:update_role:Function]
# @PURPOSE: Updates an existing role's metadata and permissions.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: role_in (RoleUpdate) - Updated role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The updated role.
# @SIDE_EFFECT: Commits updates to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.put("/roles/{role_id}", response_model=RoleSchema)
async def update_role(
role_id: str,
role_in: RoleUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.update_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role_in.name is not None:
role.name = role_in.name
if role_in.description is not None:
role.description = role_in.description
if role_in.permissions is not None:
role.permissions = []
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
role.permissions.append(perm)
db.commit()
db.refresh(role)
return role
# [/DEF:update_role:Function]
# [DEF:delete_role:Function]
# @PURPOSE: Removes a role from the system.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @SIDE_EFFECT: Deletes record from auth.db and commits.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.delete_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.delete(role)
db.commit()
return None
# [/DEF:delete_role:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available system permissions for assignment.
# @POST: Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[PermissionSchema] - List of permissions.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_permissions"):
repo = AuthRepository(db)
return repo.list_permissions()
# [/DEF:list_permissions:Function]
# [DEF:list_ad_mappings:Function]
# @PURPOSE: Lists all AD Group to Role mappings.
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("api.admin.list_ad_mappings"):
return db.query(ADGroupMapping).all()
# [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function]
# @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(
mapping_in: ADGroupMappingCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("api.admin.create_ad_mapping"):
new_mapping = ADGroupMapping(
ad_group=mapping_in.ad_group,
role_id=mapping_in.role_id
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_ad_mapping:Function]
# [/DEF:backend.src.api.routes.admin:Module]

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
@@ -29,7 +29,10 @@ router = APIRouter()
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ"))
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
@@ -49,7 +52,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
@router.patch("/global", response_model=GlobalSettings)
async def update_global_settings(
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE"))
):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings")
@@ -62,7 +66,10 @@ async def update_global_settings(
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ"))
):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
@@ -73,7 +80,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
async def update_storage_settings(
storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE"))
):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments()
@@ -106,7 +120,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE"))
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...dependencies import get_task_manager
from ...dependencies import get_task_manager, has_permission
router = APIRouter()
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
):
"""
Create and start a new task for a given plugin.