Передаем на тест
This commit is contained in:
@@ -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]
|
||||
@@ -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
|
||||
|
||||
305
backend/src/api/routes/admin.py
Normal file
305
backend/src/api/routes/admin.py
Normal 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]
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -18,7 +19,8 @@ import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
|
||||
from .api import auth
|
||||
from .core.database import init_db
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -55,6 +57,10 @@ async def shutdown_event():
|
||||
scheduler.stop()
|
||||
# [/DEF:shutdown_event:Function]
|
||||
|
||||
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
||||
from .core.auth.config import auth_config
|
||||
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
|
||||
# [/DEF:log_requests:Function]
|
||||
|
||||
# Include API routes
|
||||
app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
|
||||
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
|
||||
45
backend/src/core/auth/config.py
Normal file
45
backend/src/core/auth/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# [DEF:backend.src.core.auth.config:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, config, settings, jwt, adfs
|
||||
# @PURPOSE: Centralized configuration for authentication and authorization.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> pydantic
|
||||
#
|
||||
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthConfig:Class]
|
||||
# @PURPOSE: Holds authentication-related settings.
|
||||
# @PRE: Environment variables may be provided via .env file.
|
||||
# @POST: Returns a configuration object with validated settings.
|
||||
class AuthConfig(BaseSettings):
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Database Settings
|
||||
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
|
||||
|
||||
# ADFS Settings
|
||||
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
|
||||
ADFS_CLIENT_SECRET: str = Field(default="", env="ADFS_CLIENT_SECRET")
|
||||
ADFS_METADATA_URL: str = Field(default="", env="ADFS_METADATA_URL")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
# [/DEF:AuthConfig:Class]
|
||||
|
||||
# [DEF:auth_config:Variable]
|
||||
# @PURPOSE: Singleton instance of AuthConfig.
|
||||
auth_config = AuthConfig()
|
||||
# [/DEF:auth_config:Variable]
|
||||
|
||||
# [/DEF:backend.src.core.auth.config:Module]
|
||||
54
backend/src/core/auth/jwt.py
Normal file
54
backend/src/core/auth/jwt.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# [DEF:backend.src.core.auth.jwt:Module]
|
||||
#
|
||||
# @SEMANTICS: jwt, token, session, auth
|
||||
# @PURPOSE: JWT token generation and validation logic.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> jose
|
||||
# @RELATION: USES -> backend.src.core.auth.config.auth_config
|
||||
#
|
||||
# @INVARIANT: Tokens must include expiration time and user identifier.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from jose import JWTError, jwt
|
||||
from .config import auth_config
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:create_access_token:Function]
|
||||
# @PURPOSE: Generates a new JWT access token.
|
||||
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
|
||||
# @POST: Returns a signed JWT string.
|
||||
#
|
||||
# @PARAM: data (dict) - Payload data for the token.
|
||||
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
|
||||
# @RETURN: str - The encoded JWT.
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
with belief_scope("create_access_token"):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
|
||||
return encoded_jwt
|
||||
# [/DEF:create_access_token:Function]
|
||||
|
||||
# [DEF:decode_token:Function]
|
||||
# @PURPOSE: Decodes and validates a JWT token.
|
||||
# @PRE: token is a signed JWT string.
|
||||
# @POST: Returns the decoded payload if valid.
|
||||
#
|
||||
# @PARAM: token (str) - The JWT to decode.
|
||||
# @RETURN: dict - The decoded payload.
|
||||
# @THROW: jose.JWTError - If token is invalid or expired.
|
||||
def decode_token(token: str) -> dict:
|
||||
with belief_scope("decode_token"):
|
||||
payload = jwt.decode(token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM])
|
||||
return payload
|
||||
# [/DEF:decode_token:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.jwt:Module]
|
||||
31
backend/src/core/auth/logger.py
Normal file
31
backend/src/core/auth/logger.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# [DEF:backend.src.core.auth.logger:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, logger, audit, security
|
||||
# @PURPOSE: Audit logging for security-related events.
|
||||
# @LAYER: Core
|
||||
# @RELATION: USES -> backend.src.core.logger.belief_scope
|
||||
#
|
||||
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from ..logger import logger, belief_scope
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:log_security_event:Function]
|
||||
# @PURPOSE: Logs a security-related event for audit trails.
|
||||
# @PRE: event_type and username are strings.
|
||||
# @POST: Security event is written to the application log.
|
||||
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
|
||||
# @PARAM: username (str) - The user involved in the event.
|
||||
# @PARAM: details (dict) - Additional non-sensitive metadata.
|
||||
def log_security_event(event_type: str, username: str, details: dict = None):
|
||||
with belief_scope("log_security_event", f"{event_type}:{username}"):
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
msg = f"[AUDIT][{timestamp}][{event_type}] User: {username}"
|
||||
if details:
|
||||
msg += f" Details: {details}"
|
||||
logger.info(msg)
|
||||
# [/DEF:log_security_event:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.logger:Module]
|
||||
41
backend/src/core/auth/oauth.py
Normal file
41
backend/src/core/auth/oauth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# [DEF:backend.src.core.auth.oauth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, oauth, oidc, adfs
|
||||
# @PURPOSE: ADFS OIDC configuration and client using Authlib.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> authlib
|
||||
# @RELATION: USES -> backend.src.core.auth.config.auth_config
|
||||
#
|
||||
# @INVARIANT: Must use secure OIDC flows.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from .config import auth_config
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:oauth:Variable]
|
||||
# @PURPOSE: Global Authlib OAuth registry.
|
||||
oauth = OAuth()
|
||||
# [/DEF:oauth:Variable]
|
||||
|
||||
# [DEF:register_adfs:Function]
|
||||
# @PURPOSE: Registers the ADFS OIDC client.
|
||||
# @PRE: ADFS configuration is provided in auth_config.
|
||||
# @POST: ADFS client is registered in oauth registry.
|
||||
def register_adfs():
|
||||
if auth_config.ADFS_CLIENT_ID:
|
||||
oauth.register(
|
||||
name='adfs',
|
||||
client_id=auth_config.ADFS_CLIENT_ID,
|
||||
client_secret=auth_config.ADFS_CLIENT_SECRET,
|
||||
server_metadata_url=auth_config.ADFS_METADATA_URL,
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile groups'
|
||||
}
|
||||
)
|
||||
# [/DEF:register_adfs:Function]
|
||||
|
||||
# Initial registration
|
||||
register_adfs()
|
||||
|
||||
# [/DEF:backend.src.core.auth.oauth:Module]
|
||||
123
backend/src/core/auth/repository.py
Normal file
123
backend/src/core/auth/repository.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# [DEF:backend.src.core.auth.repository:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, repository, database, user, role
|
||||
# @PURPOSE: Data access layer for authentication-related entities.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.auth
|
||||
#
|
||||
# @INVARIANT: All database operations must be performed within a session.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthRepository:Class]
|
||||
# @PURPOSE: Encapsulates database operations for authentication.
|
||||
class AuthRepository:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the repository with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_user_by_username:Function]
|
||||
# @PURPOSE: Retrieves a user by their username.
|
||||
# @PRE: username is a string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: username (str) - The username to search for.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_username"):
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
# [/DEF:get_user_by_username:Function]
|
||||
|
||||
# [DEF:get_user_by_id:Function]
|
||||
# @PURPOSE: Retrieves a user by their unique ID.
|
||||
# @PRE: user_id is a valid UUID string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: user_id (str) - The user's unique identifier.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_id"):
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
# [/DEF:get_user_by_id:Function]
|
||||
|
||||
# [DEF:get_role_by_name:Function]
|
||||
# @PURPOSE: Retrieves a role by its name.
|
||||
# @PRE: name is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: name (str) - The role name to search for.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
def get_role_by_name(self, name: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_name"):
|
||||
return self.db.query(Role).filter(Role.name == name).first()
|
||||
# [/DEF:get_role_by_name:Function]
|
||||
|
||||
# [DEF:update_last_login:Function]
|
||||
# @PURPOSE: Updates the last_login timestamp for a user.
|
||||
# @PRE: user object is a valid User instance.
|
||||
# @POST: User's last_login is updated in the database.
|
||||
# @SIDE_EFFECT: Commits the transaction.
|
||||
# @PARAM: user (User) - The user to update.
|
||||
def update_last_login(self, user: User):
|
||||
with belief_scope("AuthRepository.update_last_login"):
|
||||
from datetime import datetime
|
||||
user.last_login = datetime.utcnow()
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
# [/DEF:update_last_login:Function]
|
||||
|
||||
# [DEF:get_role_by_id:Function]
|
||||
# @PURPOSE: Retrieves a role by its unique ID.
|
||||
# @PRE: role_id is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: role_id (str) - The role's unique identifier.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
def get_role_by_id(self, role_id: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_id"):
|
||||
return self.db.query(Role).filter(Role.id == role_id).first()
|
||||
# [/DEF:get_role_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_id:Function]
|
||||
# @PURPOSE: Retrieves a permission by its unique ID.
|
||||
# @PRE: perm_id is a string.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: perm_id (str) - The permission's unique identifier.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_id"):
|
||||
return self.db.query(Permission).filter(Permission.id == perm_id).first()
|
||||
# [/DEF:get_permission_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_resource_action:Function]
|
||||
# @PURPOSE: Retrieves a permission by resource and action.
|
||||
# @PRE: resource and action are strings.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: resource (str) - The resource name.
|
||||
# @PARAM: action (str) - The action name.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_resource_action"):
|
||||
return self.db.query(Permission).filter(
|
||||
Permission.resource == resource,
|
||||
Permission.action == action
|
||||
).first()
|
||||
# [/DEF:get_permission_by_resource_action:Function]
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @PURPOSE: Lists all available permissions.
|
||||
# @POST: Returns a list of all Permission objects.
|
||||
# @RETURN: List[Permission] - List of permissions.
|
||||
def list_permissions(self) -> List[Permission]:
|
||||
with belief_scope("AuthRepository.list_permissions"):
|
||||
return self.db.query(Permission).all()
|
||||
# [/DEF:list_permissions:Function]
|
||||
|
||||
# [/DEF:AuthRepository:Class]
|
||||
|
||||
# [/DEF:backend.src.core.auth.repository:Module]
|
||||
42
backend/src/core/auth/security.py
Normal file
42
backend/src/core/auth/security.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# [DEF:backend.src.core.auth.security:Module]
|
||||
#
|
||||
# @SEMANTICS: security, password, hashing, bcrypt
|
||||
# @PURPOSE: Utility for password hashing and verification using Passlib.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> passlib
|
||||
#
|
||||
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from passlib.context import CryptContext
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:pwd_context:Variable]
|
||||
# @PURPOSE: Passlib CryptContext for password management.
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# [/DEF:pwd_context:Variable]
|
||||
|
||||
# [DEF:verify_password:Function]
|
||||
# @PURPOSE: Verifies a plain password against a hashed password.
|
||||
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
|
||||
# @POST: Returns True if password matches, False otherwise.
|
||||
#
|
||||
# @PARAM: plain_password (str) - The unhashed password.
|
||||
# @PARAM: hashed_password (str) - The stored hash.
|
||||
# @RETURN: bool - Verification result.
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
# [/DEF:verify_password:Function]
|
||||
|
||||
# [DEF:get_password_hash:Function]
|
||||
# @PURPOSE: Generates a bcrypt hash for a plain password.
|
||||
# @PRE: password is a string.
|
||||
# @POST: Returns a secure bcrypt hash string.
|
||||
#
|
||||
# @PARAM: password (str) - The password to hash.
|
||||
# @RETURN: str - The generated hash.
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
# [/DEF:get_password_hash:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
@@ -5,6 +5,7 @@
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.mapping
|
||||
# @RELATION: USES -> backend.src.core.auth.config
|
||||
#
|
||||
# @INVARIANT: A single engine instance is used for the entire application.
|
||||
|
||||
@@ -16,44 +17,70 @@ from ..models.mapping import Base
|
||||
from ..models.task import TaskRecord
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||
from ..models.auth import User, Role, Permission, ADGroupMapping
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the main mappings database.
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
||||
# [/DEF:DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the tasks execution database.
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
||||
# [/DEF:TASKS_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:AUTH_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the authentication database.
|
||||
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
|
||||
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for mappings database.
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:engine:Variable]
|
||||
|
||||
# [DEF:tasks_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for tasks database.
|
||||
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:tasks_engine:Variable]
|
||||
|
||||
# [DEF:auth_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for authentication database.
|
||||
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:auth_engine:Variable]
|
||||
|
||||
# [DEF:SessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the main mappings database.
|
||||
# @PRE: engine is initialized.
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# [/DEF:SessionLocal:Class]
|
||||
|
||||
# [DEF:TasksSessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the tasks execution database.
|
||||
# @PRE: tasks_engine is initialized.
|
||||
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||
# [/DEF:TasksSessionLocal:Class]
|
||||
|
||||
# [DEF:AuthSessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the authentication database.
|
||||
# @PRE: auth_engine is initialized.
|
||||
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||
# [/DEF:AuthSessionLocal:Class]
|
||||
|
||||
# [DEF:init_db:Function]
|
||||
# @PURPOSE: Initializes the database by creating all tables.
|
||||
# @PRE: engine and tasks_engine are initialized.
|
||||
# @POST: Database tables created.
|
||||
# @PRE: engine, tasks_engine and auth_engine are initialized.
|
||||
# @POST: Database tables created in all databases.
|
||||
# @SIDE_EFFECT: Creates physical database files if they don't exist.
|
||||
def init_db():
|
||||
with belief_scope("init_db"):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(bind=tasks_engine)
|
||||
Base.metadata.create_all(bind=auth_engine)
|
||||
# [/DEF:init_db:Function]
|
||||
|
||||
# [DEF:get_db:Function]
|
||||
@@ -84,4 +111,18 @@ def get_tasks_db():
|
||||
db.close()
|
||||
# [/DEF:get_tasks_db:Function]
|
||||
|
||||
# [DEF:get_auth_db:Function]
|
||||
# @PURPOSE: Dependency for getting an authentication database session.
|
||||
# @PRE: AuthSessionLocal is initialized.
|
||||
# @POST: Session is closed after use.
|
||||
# @RETURN: Generator[Session, None, None]
|
||||
def get_auth_db():
|
||||
with belief_scope("get_auth_db"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:get_auth_db:Function]
|
||||
|
||||
# [/DEF:backend.src.core.database:Module]
|
||||
|
||||
@@ -68,6 +68,18 @@ class PluginBase(ABC):
|
||||
pass
|
||||
# [/DEF:version:Function]
|
||||
|
||||
@property
|
||||
# [DEF:required_permission:Function]
|
||||
# @PURPOSE: Returns the required permission string to execute this plugin.
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns string permission.
|
||||
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
|
||||
def required_permission(self) -> str:
|
||||
"""The permission string required to execute this plugin."""
|
||||
with belief_scope("required_permission"):
|
||||
return f"plugin:{self.id}:execute"
|
||||
# [/DEF:required_permission:Function]
|
||||
|
||||
@property
|
||||
# [DEF:ui_route:Function]
|
||||
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
# [DEF:Dependencies:Module]
|
||||
# @SEMANTICS: dependency, injection, singleton, factory
|
||||
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
|
||||
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
||||
# @LAYER: Core
|
||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from .core.plugin_loader import PluginLoader
|
||||
from .core.task_manager import TaskManager
|
||||
from .core.config_manager import ConfigManager
|
||||
from .core.scheduler import SchedulerService
|
||||
from .core.database import init_db
|
||||
from .core.database import init_db, get_auth_db
|
||||
from .core.logger import logger, belief_scope
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .models.auth import User
|
||||
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
@@ -77,4 +84,70 @@ def get_scheduler_service() -> SchedulerService:
|
||||
return scheduler_service
|
||||
# [/DEF:get_scheduler_service:Function]
|
||||
|
||||
# [DEF:oauth2_scheme:Variable]
|
||||
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
# [/DEF:oauth2_scheme:Variable]
|
||||
|
||||
# [DEF:get_current_user:Function]
|
||||
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
|
||||
# @PRE: JWT token provided in Authorization header.
|
||||
# @POST: Returns the User object if token is valid.
|
||||
# @THROW: HTTPException 401 if token is invalid or user not found.
|
||||
# @PARAM: token (str) - Extracted JWT token.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: User - The authenticated user.
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
|
||||
with belief_scope("get_current_user"):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
# [/DEF:get_current_user:Function]
|
||||
|
||||
# [DEF:has_permission:Function]
|
||||
# @PURPOSE: Dependency for checking if the current user has a specific permission.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns True if user has permission.
|
||||
# @THROW: HTTPException 403 if permission is denied.
|
||||
# @PARAM: resource (str) - The resource identifier.
|
||||
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
|
||||
# @RETURN: User - The authenticated user if permission granted.
|
||||
def has_permission(resource: str, action: str):
|
||||
def permission_checker(current_user: User = Depends(get_current_user)):
|
||||
with belief_scope("has_permission", f"{resource}:{action}"):
|
||||
# Union of all permissions across all roles
|
||||
for role in current_user.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.resource == resource and perm.action == action:
|
||||
return current_user
|
||||
|
||||
# Special case for Admin role (full access)
|
||||
if any(role.name == "Admin" for role in current_user.roles):
|
||||
return current_user
|
||||
|
||||
from .core.auth.logger import log_security_event
|
||||
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission denied for {resource}:{action}"
|
||||
)
|
||||
return permission_checker
|
||||
# [/DEF:has_permission:Function]
|
||||
|
||||
# [/DEF:Dependencies:Module]
|
||||
104
backend/src/models/auth.py
Normal file
104
backend/src/models/auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# [DEF:backend.src.models.auth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, models, user, role, permission, sqlalchemy
|
||||
# @PURPOSE: SQLAlchemy models for multi-user authentication and authorization.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
#
|
||||
# @INVARIANT: Usernames and emails must be unique.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from .mapping import Base
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:generate_uuid:Function]
|
||||
# @PURPOSE: Generates a unique UUID string.
|
||||
# @POST: Returns a string representation of a new UUID.
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
# [/DEF:generate_uuid:Function]
|
||||
|
||||
# [DEF:user_roles:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Users and Roles.
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:user_roles:Table]
|
||||
|
||||
# [DEF:role_permissions:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Roles and Permissions.
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", String, ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:role_permissions:Table]
|
||||
|
||||
# [DEF:User:Class]
|
||||
# @PURPOSE: Represents an identity that can authenticate to the system.
|
||||
# @RELATION: HAS_MANY -> Role (via user_roles)
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
email = Column(String, unique=True, index=True, nullable=True)
|
||||
password_hash = Column(String, nullable=True)
|
||||
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
# [/DEF:User:Class]
|
||||
|
||||
# [DEF:Role:Class]
|
||||
# @PURPOSE: Represents a collection of permissions.
|
||||
# @RELATION: HAS_MANY -> User (via user_roles)
|
||||
# @RELATION: HAS_MANY -> Permission (via role_permissions)
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
name = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
# [/DEF:Role:Class]
|
||||
|
||||
# [DEF:Permission:Class]
|
||||
# @PURPOSE: Represents a specific capability within the system.
|
||||
# @RELATION: HAS_MANY -> Role (via role_permissions)
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
resource = Column(String, nullable=False) # e.g. "plugin:backup"
|
||||
action = Column(String, nullable=False) # e.g. "READ", "EXECUTE", "WRITE"
|
||||
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
# [/DEF:Permission:Class]
|
||||
|
||||
# [DEF:ADGroupMapping:Class]
|
||||
# @PURPOSE: Maps an Active Directory group to a local System Role.
|
||||
# @RELATION: DEPENDS_ON -> Role
|
||||
class ADGroupMapping(Base):
|
||||
__tablename__ = "ad_group_mappings"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
ad_group_name = Column(String, unique=True, index=True, nullable=False)
|
||||
role_id = Column(String, ForeignKey("roles.id"), nullable=False)
|
||||
|
||||
role = relationship("Role")
|
||||
# [/DEF:ADGroupMapping:Class]
|
||||
|
||||
# [/DEF:backend.src.models.auth:Module]
|
||||
124
backend/src/schemas/auth.py
Normal file
124
backend/src/schemas/auth.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# [DEF:backend.src.schemas.auth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, schemas, pydantic, user, token
|
||||
# @PURPOSE: Pydantic schemas for authentication requests and responses.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> pydantic
|
||||
#
|
||||
# @INVARIANT: Sensitive fields like password must not be included in response schemas.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:Token:Class]
|
||||
# @PURPOSE: Represents a JWT access token response.
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
# [/DEF:Token:Class]
|
||||
|
||||
# [DEF:TokenData:Class]
|
||||
# @PURPOSE: Represents the data encoded in a JWT token.
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
scopes: List[str] = []
|
||||
# [/DEF:TokenData:Class]
|
||||
|
||||
# [DEF:PermissionSchema:Class]
|
||||
# @PURPOSE: Represents a permission in API responses.
|
||||
class PermissionSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
resource: str
|
||||
action: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:PermissionSchema:Class]
|
||||
|
||||
# [DEF:RoleSchema:Class]
|
||||
# @PURPOSE: Represents a role in API responses.
|
||||
class RoleSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permissions: List[PermissionSchema] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:RoleSchema:Class]
|
||||
|
||||
# [DEF:RoleCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new role.
|
||||
class RoleCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permissions: List[str] = [] # List of permission IDs or "resource:action" strings
|
||||
# [/DEF:RoleCreate:Class]
|
||||
|
||||
# [DEF:RoleUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing role.
|
||||
class RoleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
permissions: Optional[List[str]] = None
|
||||
# [/DEF:RoleUpdate:Class]
|
||||
|
||||
# [DEF:ADGroupMappingSchema:Class]
|
||||
# @PURPOSE: Represents an AD Group to Role mapping in API responses.
|
||||
class ADGroupMappingSchema(BaseModel):
|
||||
id: str
|
||||
ad_group: str
|
||||
role_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:ADGroupMappingSchema:Class]
|
||||
|
||||
# [DEF:ADGroupMappingCreate:Class]
|
||||
# @PURPOSE: Schema for creating an AD Group mapping.
|
||||
class ADGroupMappingCreate(BaseModel):
|
||||
ad_group: str
|
||||
role_id: str
|
||||
# [/DEF:ADGroupMappingCreate:Class]
|
||||
|
||||
# [DEF:UserBase:Class]
|
||||
# @PURPOSE: Base schema for user data.
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
email: Optional[EmailStr] = None
|
||||
is_active: bool = True
|
||||
# [/DEF:UserBase:Class]
|
||||
|
||||
# [DEF:UserCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new user.
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
roles: List[str] = []
|
||||
# [/DEF:UserCreate:Class]
|
||||
|
||||
# [DEF:UserUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing user.
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
roles: Optional[List[str]] = None
|
||||
# [/DEF:UserUpdate:Class]
|
||||
|
||||
# [DEF:User:Class]
|
||||
# @PURPOSE: Schema for user data in API responses.
|
||||
class User(UserBase):
|
||||
id: str
|
||||
auth_source: str
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
roles: List[RoleSchema] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:User:Class]
|
||||
|
||||
# [/DEF:backend.src.schemas.auth:Module]
|
||||
82
backend/src/scripts/create_admin.py
Normal file
82
backend/src/scripts/create_admin.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# [DEF:backend.src.scripts.create_admin:Module]
|
||||
#
|
||||
# @SEMANTICS: admin, setup, user, auth, cli
|
||||
# @PURPOSE: CLI tool for creating the initial admin user.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: USES -> backend.src.core.auth.security
|
||||
# @RELATION: USES -> backend.src.core.database
|
||||
# @RELATION: USES -> backend.src.models.auth
|
||||
#
|
||||
# @INVARIANT: Admin user must have the "Admin" role.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import AuthSessionLocal, init_db
|
||||
from src.core.auth.security import get_password_hash
|
||||
from src.models.auth import User, Role, Permission
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:create_admin:Function]
|
||||
# @PURPOSE: Creates an admin user and necessary roles/permissions.
|
||||
# @PRE: username and password provided via CLI.
|
||||
# @POST: Admin user exists in auth.db.
|
||||
#
|
||||
# @PARAM: username (str) - Admin username.
|
||||
# @PARAM: password (str) - Admin password.
|
||||
def create_admin(username, password):
|
||||
with belief_scope("create_admin"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
# 1. Ensure Admin role exists
|
||||
admin_role = db.query(Role).filter(Role.name == "Admin").first()
|
||||
if not admin_role:
|
||||
logger.info("Creating Admin role...")
|
||||
admin_role = Role(name="Admin", description="System Administrator")
|
||||
db.add(admin_role)
|
||||
db.commit()
|
||||
db.refresh(admin_role)
|
||||
|
||||
# 2. Check if user already exists
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
logger.warning(f"User {username} already exists.")
|
||||
return
|
||||
|
||||
# 3. Create Admin user
|
||||
logger.info(f"Creating admin user: {username}")
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
auth_source="LOCAL",
|
||||
is_active=True
|
||||
)
|
||||
new_user.roles.append(admin_role)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
logger.info(f"Admin user {username} created successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create admin user: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:create_admin:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Create initial admin user")
|
||||
parser.add_argument("--username", required=True, help="Admin username")
|
||||
parser.add_argument("--password", required=True, help="Admin password")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Ensure DB is initialized before creating admin
|
||||
init_db()
|
||||
create_admin(args.username, args.password)
|
||||
|
||||
# [/DEF:backend.src.scripts.create_admin:Module]
|
||||
44
backend/src/scripts/init_auth_db.py
Normal file
44
backend/src/scripts/init_auth_db.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# [DEF:backend.src.scripts.init_auth_db:Module]
|
||||
#
|
||||
# @SEMANTICS: setup, database, auth, migration
|
||||
# @PURPOSE: Initializes the auth database and creates the necessary tables.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: CALLS -> backend.src.core.database.init_db
|
||||
#
|
||||
# @INVARIANT: Safe to run multiple times (idempotent).
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import init_db, auth_engine
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.scripts.seed_permissions import seed_permissions
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:run_init:Function]
|
||||
# @PURPOSE: Main entry point for the initialization script.
|
||||
# @POST: auth.db is initialized with the correct schema and seeded permissions.
|
||||
def run_init():
|
||||
with belief_scope("init_auth_db"):
|
||||
logger.info("Initializing authentication database...")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("Authentication database initialized successfully.")
|
||||
|
||||
# Seed permissions
|
||||
seed_permissions()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize authentication database: {e}")
|
||||
sys.exit(1)
|
||||
# [/DEF:run_init:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_init()
|
||||
|
||||
# [/DEF:backend.src.scripts.init_auth_db:Module]
|
||||
79
backend/src/scripts/seed_permissions.py
Normal file
79
backend/src/scripts/seed_permissions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# [DEF:backend.src.scripts.seed_permissions:Module]
|
||||
#
|
||||
# @SEMANTICS: setup, database, auth, permissions, seeding
|
||||
# @PURPOSE: Populates the auth database with initial system permissions.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: USES -> backend.src.core.database.get_auth_db
|
||||
# @RELATION: USES -> backend.src.models.auth.Permission
|
||||
#
|
||||
# @INVARIANT: Safe to run multiple times (idempotent).
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import AuthSessionLocal
|
||||
from src.models.auth import Permission
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:INITIAL_PERMISSIONS:Constant]
|
||||
INITIAL_PERMISSIONS = [
|
||||
# Admin Permissions
|
||||
{"resource": "admin:users", "action": "READ"},
|
||||
{"resource": "admin:users", "action": "WRITE"},
|
||||
{"resource": "admin:roles", "action": "READ"},
|
||||
{"resource": "admin:roles", "action": "WRITE"},
|
||||
{"resource": "admin:settings", "action": "READ"},
|
||||
{"resource": "admin:settings", "action": "WRITE"},
|
||||
|
||||
# Plugin Permissions
|
||||
{"resource": "plugin:backup", "action": "EXECUTE"},
|
||||
{"resource": "plugin:migration", "action": "EXECUTE"},
|
||||
{"resource": "plugin:mapper", "action": "EXECUTE"},
|
||||
{"resource": "plugin:search", "action": "EXECUTE"},
|
||||
{"resource": "plugin:git", "action": "EXECUTE"},
|
||||
{"resource": "plugin:storage", "action": "EXECUTE"},
|
||||
{"resource": "plugin:debug", "action": "EXECUTE"},
|
||||
]
|
||||
# [/DEF:INITIAL_PERMISSIONS:Constant]
|
||||
|
||||
# [DEF:seed_permissions:Function]
|
||||
# @PURPOSE: Inserts missing permissions into the database.
|
||||
# @POST: All INITIAL_PERMISSIONS exist in the DB.
|
||||
def seed_permissions():
|
||||
with belief_scope("seed_permissions"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
logger.info("Seeding permissions...")
|
||||
count = 0
|
||||
for perm_data in INITIAL_PERMISSIONS:
|
||||
exists = db.query(Permission).filter(
|
||||
Permission.resource == perm_data["resource"],
|
||||
Permission.action == perm_data["action"]
|
||||
).first()
|
||||
|
||||
if not exists:
|
||||
new_perm = Permission(
|
||||
resource=perm_data["resource"],
|
||||
action=perm_data["action"]
|
||||
)
|
||||
db.add(new_perm)
|
||||
count += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Seeding completed. Added {count} new permissions.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed permissions: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:seed_permissions:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_permissions()
|
||||
|
||||
# [/DEF:backend.src.scripts.seed_permissions:Module]
|
||||
115
backend/src/services/auth_service.py
Normal file
115
backend/src/services/auth_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# [DEF:backend.src.services.auth_service:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, service, business-logic, login, jwt
|
||||
# @PURPOSE: Orchestrates authentication business logic.
|
||||
# @LAYER: Service
|
||||
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
|
||||
# @RELATION: USES -> backend.src.core.auth.security
|
||||
# @RELATION: USES -> backend.src.core.auth.jwt
|
||||
#
|
||||
# @INVARIANT: Authentication must verify both credentials and account status.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.auth import User, Role
|
||||
from ..core.auth.repository import AuthRepository
|
||||
from ..core.auth.security import verify_password, get_password_hash
|
||||
from ..core.auth.jwt import create_access_token
|
||||
from ..core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthService:Class]
|
||||
# @PURPOSE: Provides high-level authentication services.
|
||||
class AuthService:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the service with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.repo = AuthRepository(db)
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:authenticate_user:Function]
|
||||
# @PURPOSE: Authenticates a user with username and password.
|
||||
# @PRE: username and password are provided.
|
||||
# @POST: Returns User object if authentication succeeds, else None.
|
||||
# @SIDE_EFFECT: Updates last_login timestamp on success.
|
||||
# @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):
|
||||
with belief_scope("AuthService.authenticate_user"):
|
||||
user = self.repo.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
return None
|
||||
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
self.repo.update_last_login(user)
|
||||
return user
|
||||
# [/DEF:authenticate_user:Function]
|
||||
|
||||
# [DEF:create_session:Function]
|
||||
# @PURPOSE: Creates a JWT session for an authenticated user.
|
||||
# @PRE: user is a valid User object.
|
||||
# @POST: Returns a dictionary with access_token and token_type.
|
||||
# @PARAM: user (User) - The authenticated user.
|
||||
# @RETURN: Dict[str, str] - Session data.
|
||||
def create_session(self, user) -> Dict[str, str]:
|
||||
with belief_scope("AuthService.create_session"):
|
||||
# Collect role names for scopes
|
||||
scopes = [role.name for role in user.roles]
|
||||
|
||||
token_data = {
|
||||
"sub": user.username,
|
||||
"scopes": scopes
|
||||
}
|
||||
|
||||
access_token = create_access_token(data=token_data)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
# [/DEF:create_session:Function]
|
||||
|
||||
# [DEF:provision_adfs_user:Function]
|
||||
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
|
||||
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
|
||||
# @POST: User is created/updated and assigned roles based on groups.
|
||||
# @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("AuthService.provision_adfs_user"):
|
||||
username = user_info.get("upn") or user_info.get("email")
|
||||
email = user_info.get("email")
|
||||
ad_groups = user_info.get("groups", [])
|
||||
|
||||
user = self.repo.get_user_by_username(username)
|
||||
if not user:
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
auth_source="ADFS",
|
||||
is_active=True
|
||||
)
|
||||
self.repo.db.add(user)
|
||||
|
||||
# Update roles based on group mappings
|
||||
from ..models.auth import ADGroupMapping
|
||||
mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter(
|
||||
ADGroupMapping.ad_group_name.in_(ad_groups)
|
||||
).all()
|
||||
|
||||
user.roles = mapped_roles
|
||||
self.repo.db.commit()
|
||||
self.repo.db.refresh(user)
|
||||
return user
|
||||
# [/DEF:provision_adfs_user:Function]
|
||||
|
||||
# [/DEF:AuthService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.auth_service:Module]
|
||||
Reference in New Issue
Block a user