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

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

1
.gitignore vendored
View File

@@ -67,3 +67,4 @@ backend/mappings.db
backend/tasks.db backend/tasks.db
backend/logs backend/logs
backend/auth.db

View File

@@ -0,0 +1,4 @@
---
description: USE SEMANTIC
---
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке

View File

@@ -1,8 +1,8 @@
<!-- <!--
SYNC IMPACT REPORT SYNC IMPACT REPORT
Version: 1.8.0 (Frontend Unification) Version: 1.9.0 (Security & RBAC Mandate)
Changes: Changes:
- Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n). - Added Principle IX: Security & Access Control (Mandating granular permissions for plugins).
Templates Status: Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned. - .specify/templates/plan-template.md: ✅ Aligned.
- .specify/templates/spec-template.md: ✅ Aligned. - .specify/templates/spec-template.md: ✅ Aligned.
@@ -41,6 +41,11 @@ To ensure a consistent and accessible user experience, all frontend implementati
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited. - **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited. - **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
### IX. Security & Access Control
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
- **Registration**: These permissions MUST be registered in the system database during initialization or plugin loading to ensure they are available for role assignment in the Admin UI.
## File Structure Standards ## File Structure Standards
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of: Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
- Python Module Headers (`.py`) - Python Module Headers (`.py`)
@@ -68,4 +73,4 @@ This Constitution establishes the "Semantic Code Generation Protocol" as the sup
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update. - **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure. - **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 1.8.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-26 **Version**: 1.9.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-27

BIN
backend/backend/auth.db Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -25,9 +25,13 @@ keyring==25.7.0
more-itertools==10.8.0 more-itertools==10.8.0
pycparser==2.23 pycparser==2.23
pydantic==2.12.5 pydantic==2.12.5
pydantic-settings
pydantic_core==2.41.5 pydantic_core==2.41.5
python-multipart==0.0.21 python-multipart==0.0.21
PyYAML==6.0.3 PyYAML==6.0.3
passlib[bcrypt]
python-jose[cryptography]
PyJWT
RapidFuzz==3.14.3 RapidFuzz==3.14.3
referencing==0.37.0 referencing==0.37.0
requests==2.32.5 requests==2.32.5
@@ -44,4 +48,6 @@ websockets==15.0.1
pandas pandas
psycopg2-binary psycopg2-binary
openpyxl openpyxl
GitPython==3.1.44 GitPython==3.1.44
itsdangerous
email-validator

View File

@@ -1,59 +1,108 @@
# [DEF:AuthModule:Module] # [DEF:backend.src.api.auth:Module]
# @SEMANTICS: auth, authentication, adfs, oauth, middleware #
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints. # @SEMANTICS: api, auth, routes, login, logout
# @LAYER: UI (API) # @PURPOSE: Authentication API endpoints.
# @RELATION: Used by API routers to protect endpoints that require authentication. # @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 # [SECTION: IMPORTS]
from fastapi.security import OAuth2AuthorizationCodeBearer from fastapi import APIRouter, Depends, HTTPException, status
from authlib.integrations.starlette_client import OAuth from fastapi.security import OAuth2PasswordRequestForm
from starlette.config import Config 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. # [DEF:router:Variable]
# Create an in-memory .env file # @PURPOSE: APIRouter instance for authentication routes.
from io import StringIO router = APIRouter(prefix="/api/auth", tags=["auth"])
config_data = StringIO(""" # [/DEF:router:Variable]
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)
oauth.register( # [DEF:login_for_access_token:Function]
name='adfs', # @PURPOSE: Authenticates a user and returns a JWT access token.
server_metadata_url=config('ADFS_SERVER_METADATA_URL'), # @PRE: form_data contains username and password.
client_kwargs={'scope': 'openid profile email'} # @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( # [DEF:read_users_me:Function]
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize", # @PURPOSE: Retrieves the profile of the currently authenticated user.
tokenUrl="https://your-adfs-server/adfs/oauth2/token", # @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] # [DEF:logout:Function]
# @PURPOSE: Dependency to get the current user from the ADFS token. # @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PARAM: token (str) - The OAuth2 bearer token. # @PRE: Valid JWT token provided.
# @PRE: token should be provided via Authorization header. # @POST: Returns success message.
# @POST: Returns user details if authenticated, else raises 401. @router.post("/logout")
# @RETURN: Dict[str, str] - User information. async def logout(current_user: UserSchema = Depends(get_current_user)):
async def get_current_user(token: str = Depends(oauth2_scheme)): with belief_scope("api.auth.logout"):
""" log_security_event("LOGOUT", current_user.username)
Dependency to get the current user from the ADFS token. # In a stateless JWT setup, client-side token deletion is primary.
This is a placeholder and needs to be fully implemented. # Server-side revocation (blacklisting) can be added here if needed.
""" return {"message": "Successfully logged out"}
# In a real implementation, you would: # [/DEF:logout:Function]
# 1. Validate the token with ADFS.
# 2. Fetch user information. # [DEF:login_adfs:Function]
# 3. Create a user object. # @PURPOSE: Initiates the ADFS OIDC login flow.
# For now, we'll just check if a token exists. # @POST: Redirects the user to ADFS.
if not token: @router.get("/login/adfs")
raise HTTPException( async def login_adfs(request: starlette.requests.Request):
status_code=status.HTTP_401_UNAUTHORIZED, with belief_scope("api.auth.login_adfs"):
detail="Not authenticated", redirect_uri = request.url_for('auth_callback_adfs')
headers={"WWW-Authenticate": "Bearer"}, return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
) # [/DEF:login_adfs:Function]
# A real implementation would return a user object.
return {"placeholder_user": "user@example.com"} # [DEF:auth_callback_adfs:Function]
# [/DEF:get_current_user:Function] # @PURPOSE: Handles the callback from ADFS after successful authentication.
# [/DEF:AuthModule:Module] # @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 typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig 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.config_manager import ConfigManager
from ...core.logger import logger, belief_scope from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
@@ -29,7 +29,10 @@ router = APIRouter()
# @POST: Returns masked AppConfig. # @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration. # @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig) @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"): with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings") logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True) 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) @router.patch("/global", response_model=GlobalSettings)
async def update_global_settings( async def update_global_settings(
settings: GlobalSettings, 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"): with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating 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. # @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration. # @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig) @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"): with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function] # [/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. # @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings. # @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig) @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"): with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path) is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid: if not is_valid:
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
# @POST: Returns list of environments. # @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments. # @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment]) @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"): with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments") logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_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) @router.post("/environments", response_model=Environment)
async def add_environment( async def add_environment(
env: 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"): with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}") 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.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry 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() router = APIRouter()
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
# @RETURN: Task - The created task instance. # @RETURN: Task - The created task instance.
async def create_task( async def create_task(
request: CreateTaskRequest, 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. Create and start a new task for a given plugin.

View File

@@ -10,6 +10,7 @@ from pathlib import Path
project_root = Path(__file__).resolve().parent.parent.parent project_root = Path(__file__).resolve().parent.parent.parent
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -18,7 +19,8 @@ import os
from .dependencies import get_task_manager, get_scheduler_service from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope 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 from .core.database import init_db
# [DEF:App:Global] # [DEF:App:Global]
@@ -55,6 +57,10 @@ async def shutdown_event():
scheduler.stop() scheduler.stop()
# [/DEF:shutdown_event:Function] # [/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 # Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
# [/DEF:log_requests:Function] # [/DEF:log_requests:Function]
# Include API routes # 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(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"]) app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])

View 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]

View 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]

View 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]

View 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]

View 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]

View 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]

View File

@@ -5,6 +5,7 @@
# @LAYER: Core # @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy # @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping # @RELATION: USES -> backend.src.models.mapping
# @RELATION: USES -> backend.src.core.auth.config
# #
# @INVARIANT: A single engine instance is used for the entire application. # @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.task import TaskRecord
from ..models.connection import ConnectionConfig from ..models.connection import ConnectionConfig
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
from ..models.auth import User, Role, Permission, ADGroupMapping
from .logger import belief_scope from .logger import belief_scope
from .auth.config import auth_config
import os import os
# [/SECTION] # [/SECTION]
# [DEF:DATABASE_URL:Constant] # [DEF:DATABASE_URL:Constant]
# @PURPOSE: URL for the main mappings database.
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
# [/DEF:DATABASE_URL:Constant] # [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_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") TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
# [/DEF:TASKS_DATABASE_URL:Constant] # [/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] # [DEF:engine:Variable]
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:engine:Variable] # [/DEF:engine:Variable]
# [DEF:tasks_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}) tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:tasks_engine:Variable] # [/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] # [DEF:SessionLocal:Class]
# @PURPOSE: A session factory for the main mappings database. # @PURPOSE: A session factory for the main mappings database.
# @PRE: engine is initialized.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal:Class] # [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class] # [DEF:TasksSessionLocal:Class]
# @PURPOSE: A session factory for the tasks execution database. # @PURPOSE: A session factory for the tasks execution database.
# @PRE: tasks_engine is initialized.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine) TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class] # [/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] # [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables. # @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine and tasks_engine are initialized. # @PRE: engine, tasks_engine and auth_engine are initialized.
# @POST: Database tables created. # @POST: Database tables created in all databases.
# @SIDE_EFFECT: Creates physical database files if they don't exist.
def init_db(): def init_db():
with belief_scope("init_db"): with belief_scope("init_db"):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine) Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
# [/DEF:init_db:Function] # [/DEF:init_db:Function]
# [DEF:get_db:Function] # [DEF:get_db:Function]
@@ -84,4 +111,18 @@ def get_tasks_db():
db.close() db.close()
# [/DEF:get_tasks_db:Function] # [/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] # [/DEF:backend.src.core.database:Module]

View File

@@ -68,6 +68,18 @@ class PluginBase(ABC):
pass pass
# [/DEF:version:Function] # [/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 @property
# [DEF:ui_route:Function] # [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable. # @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.

View File

@@ -1,16 +1,23 @@
# [DEF:Dependencies:Module] # [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. # @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core # @LAYER: Core
# @RELATION: Used by the main app and API routers to get access to shared instances. # @RELATION: Used by the main app and API routers to get access to shared instances.
from pathlib import Path 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.plugin_loader import PluginLoader
from .core.task_manager import TaskManager from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService 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.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 # Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD # 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 return scheduler_service
# [/DEF:get_scheduler_service:Function] # [/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] # [/DEF:Dependencies:Module]

104
backend/src/models/auth.py Normal file
View 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
View 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]

View 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]

View 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]

View 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]

View 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]

Binary file not shown.

View File

@@ -9,6 +9,13 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { LanguageSwitcher } from '$lib/ui'; import { LanguageSwitcher } from '$lib/ui';
import { auth } from '../lib/auth/store';
import { goto } from '$app/navigation';
function handleLogout() {
auth.logout();
goto('/login');
}
</script> </script>
<header class="bg-white shadow-md p-4 flex justify-between items-center"> <header class="bg-white shadow-md p-4 flex justify-between items-center">
@@ -41,7 +48,32 @@
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a> <a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
</div> </div>
</div> </div>
{#if $auth.isAuthenticated && $auth.user?.roles?.some(r => r.name === 'Admin')}
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.admin}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_users}</a>
<a href="/admin/roles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_roles}</a>
<a href="/admin/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_settings}</a>
</div>
</div>
{/if}
<LanguageSwitcher /> <LanguageSwitcher />
{#if $auth.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-gray-600">{$auth.user?.username}</span>
<button
on:click={handleLogout}
class="text-sm text-red-600 hover:text-red-800 font-medium"
>
Logout
</button>
</div>
{/if}
</nav> </nav>
</header> </header>
<!-- [/DEF:Navbar:Component] --> <!-- [/DEF:Navbar:Component] -->

View File

@@ -0,0 +1,61 @@
<!-- [DEF:ProtectedRoute:Component] -->
<!--
@SEMANTICS: auth, guard, route, protection
@PURPOSE: Wraps content to ensure only authenticated users can access it.
@LAYER: Component
@RELATION: USES -> authStore
@RELATION: CALLS -> goto
@INVARIANT: Redirects to /login if user is not authenticated.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
// [SECTION: TEMPLATE]
// Only render slot if authenticated
// [/SECTION: TEMPLATE]
onMount(async () => {
// Check if we have a token but no user profile yet
if ($auth.token && !$auth.user) {
auth.setLoading(true);
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${$auth.token}`
}
});
if (response.ok) {
const user = await response.json();
auth.setUser(user);
} else {
// Token invalid or expired
auth.logout();
goto('/login');
}
} catch (e) {
console.error('Failed to verify session:', e);
auth.logout();
goto('/login');
} finally {
auth.setLoading(false);
}
} else if (!$auth.token) {
goto('/login');
}
});
</script>
{#if $auth.loading}
<div class="flex items-center justify-center min-h-screen">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated}
<slot />
{/if}
<!-- [/DEF:ProtectedRoute:Component] -->

View File

@@ -25,6 +25,22 @@ export const getWsUrl = (taskId) => {
}; };
// [/DEF:getWsUrl:Function] // [/DEF:getWsUrl:Function]
// [DEF:getAuthHeaders:Function]
// @PURPOSE: Returns headers with Authorization if token exists.
function getAuthHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
// [/DEF:getAuthHeaders:Function]
// [DEF:fetchApi:Function] // [DEF:fetchApi:Function]
// @PURPOSE: Generic GET request wrapper. // @PURPOSE: Generic GET request wrapper.
// @PRE: endpoint string is provided. // @PRE: endpoint string is provided.
@@ -34,7 +50,9 @@ export const getWsUrl = (taskId) => {
async function fetchApi(endpoint) { async function fetchApi(endpoint) {
try { try {
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`); console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`); const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`); throw new Error(`API request failed with status ${response.status}`);
} }
@@ -59,9 +77,7 @@ async function postApi(endpoint, body) {
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`); console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST', method: 'POST',
headers: { headers: getAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
@@ -85,9 +101,7 @@ async function requestApi(endpoint, method = 'GET', body = null) {
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`); console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
const options = { const options = {
method, method,
headers: { headers: getAuthHeaders(),
'Content-Type': 'application/json',
},
}; };
if (body) { if (body) {
options.body = JSON.stringify(body); options.body = JSON.stringify(body);
@@ -112,6 +126,9 @@ async function requestApi(endpoint, method = 'GET', body = null) {
// [DEF:api:Data] // [DEF:api:Data]
// @PURPOSE: API client object with specific methods. // @PURPOSE: API client object with specific methods.
export const api = { export const api = {
fetchApi,
postApi,
requestApi,
getPlugins: () => fetchApi('/plugins'), getPlugins: () => fetchApi('/plugins'),
getTasks: () => fetchApi('/tasks'), getTasks: () => fetchApi('/tasks'),
getTask: (taskId) => fetchApi(`/tasks/${taskId}`), getTask: (taskId) => fetchApi(`/tasks/${taskId}`),

View File

@@ -3,16 +3,28 @@
import Navbar from '../components/Navbar.svelte'; import Navbar from '../components/Navbar.svelte';
import Footer from '../components/Footer.svelte'; import Footer from '../components/Footer.svelte';
import Toast from '../components/Toast.svelte'; import Toast from '../components/Toast.svelte';
import ProtectedRoute from '../components/auth/ProtectedRoute.svelte';
import { page } from '$app/stores';
$: isLoginPage = $page.url.pathname === '/login';
</script> </script>
<Toast /> <Toast />
<main class="bg-gray-50 min-h-screen flex flex-col"> <main class="bg-gray-50 min-h-screen flex flex-col">
<Navbar /> {#if isLoginPage}
<div class="p-4 flex-grow">
<slot />
</div>
{:else}
<ProtectedRoute>
<Navbar />
<div class="p-4 flex-grow"> <div class="p-4 flex-grow">
<slot /> <slot />
</div> </div>
<Footer /> <Footer />
</ProtectedRoute>
{/if}
</main> </main>

View File

@@ -0,0 +1,216 @@
<!-- [DEF:AdminRolesPage:Component] -->
<!--
@SEMANTICS: admin, role-management, rbac
@PURPOSE: UI for managing system roles and their permissions.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with Admin role.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let roles = [];
let permissions = [];
let loading = true;
let error = null;
let showModal = false;
let isEditing = false;
let currentRoleId = null;
let roleForm = {
name: '',
description: '',
permissions: []
};
// [DEF:loadData:Function]
/**
* @purpose Fetches roles and available permissions.
* @pre Component mounted.
* @post roles and permissions arrays populated.
*/
async function loadData() {
console.log('[AdminRolesPage][loadData][Entry]');
loading = true;
try {
[roles, permissions] = await Promise.all([
adminService.getRoles(),
adminService.getPermissions()
]);
console.log('[AdminRolesPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles data.";
console.error('[AdminRolesPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
function openCreateModal() {
isEditing = false;
currentRoleId = null;
roleForm = { name: '', description: '', permissions: [] };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
function openEditModal(role) {
isEditing = true;
currentRoleId = role.id;
roleForm = {
name: role.name,
description: role.description || '',
permissions: role.permissions.map(p => p.id)
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveRole:Function]
/**
* @purpose Submits role data (create or update).
*/
async function handleSaveRole() {
console.log('[AdminRolesPage][handleSaveRole][Entry]');
try {
if (isEditing) {
await adminService.updateRole(currentRoleId, roleForm);
} else {
await adminService.createRole(roleForm);
}
showModal = false;
await loadData();
console.log('[AdminRolesPage][handleSaveRole][Coherence:OK]');
} catch (e) {
alert("Failed to save role: " + e.message);
console.error('[AdminRolesPage][handleSaveRole][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveRole:Function]
// [DEF:handleDeleteRole:Function]
async function handleDeleteRole(role) {
if (!confirm($t.admin.roles.confirm_delete.replace('{name}', role.name))) return;
console.log('[AdminRolesPage][handleDeleteRole][Entry]');
try {
await adminService.deleteRole(role.id);
await loadData();
console.log('[AdminRolesPage][handleDeleteRole][Coherence:OK]');
} catch (e) {
alert("Failed to delete role: " + e.message);
console.error('[AdminRolesPage][handleDeleteRole][Coherence:Failed]', e);
}
}
// [/DEF:handleDeleteRole:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:roles">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.roles.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={openCreateModal}
>
{$t.admin.roles.create}
</button>
</div>
{#if loading}
<p>{$t.admin.roles.loading}</p>
{:else if error}
<div class="bg-red-100 text-red-700 p-4 rounded">{error}</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.description}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.permissions}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each roles as role}
<tr>
<td class="px-6 py-4 whitespace-nowrap font-medium">{role.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{role.description || '-'}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{#each role.permissions as perm}
<span class="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100">
{perm.resource}:{perm.action}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(role)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
<button on:click={() => handleDeleteRole(role)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-bold mb-4">
{isEditing ? $t.admin.roles.modal_edit_title : $t.admin.roles.modal_create_title}
</h2>
<form on:submit|preventDefault={handleSaveRole}>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.name}</label>
<input type="text" bind:value={roleForm.name} class="w-full border p-2 rounded" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.description}</label>
<textarea bind:value={roleForm.description} class="w-full border p-2 rounded h-20"></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">{$t.admin.roles.permissions}</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 border p-3 rounded bg-gray-50">
{#each permissions as perm}
<label class="flex items-center space-x-2 p-1 hover:bg-white rounded cursor-pointer">
<input type="checkbox" value={perm.id} bind:group={roleForm.permissions} class="rounded text-blue-600" />
<span class="text-xs">{perm.resource}:{perm.action}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">{$t.admin.roles.permissions_hint}</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminRolesPage:Component] -->

View File

@@ -0,0 +1,213 @@
<!-- [DEF:AdminSettingsPage:Component] -->
<!--
@SEMANTICS: admin, adfs, mappings, configuration
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:settings" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let mappings = [];
let roles = [];
let loading = true;
let error = null;
let showCreateModal = false;
let newMapping = {
ad_group: '',
role_id: ''
};
// [DEF:loadData:Function]
/**
* @purpose Fetches AD mappings and roles from the backend to populate the UI.
* @pre Component is mounted and user has active session.
* @post mappings and roles variables are updated with backend data.
* @returns {Promise<void>}
* @side_effect Updates local 'mappings', 'roles', 'loading', and 'error' states.
* @relation CALLS -> adminService.getRoles
* @relation CALLS -> adminService.getADGroupMappings
*/
async function loadData() {
console.log('[AdminSettingsPage][loadData][Entry]');
loading = true;
try {
// Fetch roles first as they are required for displaying mapping labels
roles = await adminService.getRoles();
try {
mappings = await adminService.getADGroupMappings();
} catch (e) {
console.warn("[AdminSettingsPage][loadData] AD Mappings endpoint potentially unavailable.");
}
console.log('[AdminSettingsPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles or configuration.";
console.error('[AdminSettingsPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:handleCreateMapping:Function]
/**
* @purpose Submits a new AD Group to Role mapping to the backend.
* @pre 'newMapping' object contains valid 'ad_group' and 'role_id'.
* @post A new mapping is created in the database and the table is refreshed.
* @returns {Promise<void>}
* @side_effect Closes the modal on success, shows alert on failure.
* @relation CALLS -> adminService.createADGroupMapping
*/
async function handleCreateMapping() {
console.log('[AdminSettingsPage][handleCreateMapping][Entry]');
// Guard Clause (@PRE)
if (!newMapping.ad_group || !newMapping.role_id) {
alert("Please fill in all fields.");
return;
}
try {
await adminService.createADGroupMapping(newMapping);
showCreateModal = false;
// Reset form
newMapping = { ad_group: '', role_id: '' };
await loadData();
console.log('[AdminSettingsPage][handleCreateMapping][Coherence:OK]');
} catch (e) {
alert("Failed to create mapping: " + (e.message || "Unknown error"));
console.error('[AdminSettingsPage][handleCreateMapping][Coherence:Failed]', e);
}
}
// [/DEF:handleCreateMapping:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:settings">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.settings.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={() => showCreateModal = true}
>
{$t.admin.settings.add_mapping}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4" role="alert">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.ad_group}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.local_role}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each mappings as mapping}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm text-gray-600">{mapping.ad_group}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
{roles.find(r => r.id === mapping.role_id)?.name || mapping.role_id}
</span>
</td>
</tr>
{/each}
{#if mappings.length === 0}
<tr>
<td colspan="2" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>{$t.admin.settings.no_mappings}</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showCreateModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">{$t.admin.settings.modal_title}</h2>
<form on:submit|preventDefault={handleCreateMapping}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.ad_group_dn}</label>
<input
type="text"
bind:value={newMapping.ad_group}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g. CN=SS_ADMINS,OU=Groups,DC=org"
required
/>
<p class="text-xs text-gray-500 mt-1">{$t.admin.settings.ad_group_hint}</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.local_role_select}</label>
<select
bind:value={newMapping.role_id}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="" disabled>{$t.admin.settings.select_role}</option>
{#each roles as role}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium"
on:click={() => showCreateModal = false}
>
{$t.common.cancel}
</button>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md"
>
{$t.common.save}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminSettingsPage:Component] -->

View File

@@ -0,0 +1,273 @@
<!-- [DEF:AdminUsersPage:Component] -->
<!--
@SEMANTICS: admin, user-management, rbac
@PURPOSE: UI for managing system users and their roles.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:users" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let users = [];
let roles = [];
let loading = true;
let error = null;
let showModal = false;
let isEditing = false;
let currentUserId = null;
let userForm = {
username: '',
email: '',
password: '',
roles: [],
is_active: true
};
// [DEF:loadData:Function]
/**
* @purpose Fetches users and roles from the backend.
* @pre Component mounted.
* @post users and roles arrays populated.
*/
async function loadData() {
console.log('[AdminUsersPage][loadData][Entry]');
loading = true;
try {
[users, roles] = await Promise.all([
adminService.getUsers(),
adminService.getRoles()
]);
console.log('[AdminUsersPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load admin data.";
console.error('[AdminUsersPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
/**
* @purpose Prepares the form for creating a new user.
* @post showModal is true, isEditing is false, userForm is reset.
*/
function openCreateModal() {
isEditing = false;
currentUserId = null;
userForm = { username: '', email: '', password: '', roles: [], is_active: true };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
/**
* @purpose Prepares the form for editing an existing user.
* @pre user object must be valid.
* @post showModal is true, isEditing is true, userForm populated with user data.
* @param {Object} user - The user object to edit.
*/
function openEditModal(user) {
isEditing = true;
currentUserId = user.id;
userForm = {
username: user.username,
email: user.email,
password: '',
roles: user.roles.map(r => r.name),
is_active: user.is_active
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveUser:Function]
/**
* @purpose Submits user data to the backend (create or update).
* @pre userForm must be valid.
* @post User created or updated, modal closed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.createUser
* @relation CALLS -> adminService.updateUser
*/
async function handleSaveUser() {
console.log('[AdminUsersPage][handleSaveUser][Entry]');
try {
if (isEditing) {
const updateData = { ...userForm };
if (!updateData.password) delete updateData.password;
await adminService.updateUser(currentUserId, updateData);
} else {
await adminService.createUser(userForm);
}
showModal = false;
await loadData();
console.log('[AdminUsersPage][handleSaveUser][Coherence:OK]');
} catch (e) {
alert("Failed to save user: " + e.message);
console.error('[AdminUsersPage][handleSaveUser][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveUser:Function]
// [DEF:handleDeleteUser:Function]
/**
* @purpose Deletes a user after confirmation.
* @pre user object must be valid.
* @post User deleted if confirmed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.deleteUser
* @param {Object} user - The user to delete.
*/
async function handleDeleteUser(user) {
if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return;
console.log('[AdminUsersPage][handleDeleteUser][Entry]');
try {
await adminService.deleteUser(user.id);
await loadData();
console.log('[AdminUsersPage][handleDeleteUser][Coherence:OK]');
} catch (e) {
alert("Failed to delete user: " + e.message);
console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e);
}
}
// [/DEF:handleDeleteUser:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:users">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.users.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={openCreateModal}
>
{$t.admin.users.create}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.username}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.email}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.source}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.roles}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.status}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each users as user}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-medium">{user.username}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full {user.auth_source === 'LOCAL' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}">
{user.auth_source}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
{#each user.roles as role}
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded border border-gray-200">{role.name}</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="flex items-center">
<span class="h-2 w-2 rounded-full mr-2 {user.is_active ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm {user.is_active ? 'text-green-700' : 'text-red-700'}">
{user.is_active ? $t.admin.users.active : $t.admin.users.inactive}
</span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
<button on:click={() => handleDeleteUser(user)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">
{isEditing ? $t.admin.users.modal_edit_title : $t.admin.users.modal_title}
</h2>
<form on:submit|preventDefault={handleSaveUser}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.username}</label>
<input type="text" bind:value={userForm.username} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.email}</label>
<input type="email" bind:value={userForm.email} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.password}</label>
<input type="password" bind:value={userForm.password} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required={!isEditing} />
{#if isEditing}
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.password_hint}</p>
{/if}
</div>
<div class="mb-4">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" bind:checked={userForm.is_active} class="rounded text-blue-600 focus:ring-blue-500" />
<span class="text-sm font-medium text-gray-700">{$t.admin.users.active}</span>
</label>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.roles}</label>
<select multiple bind:value={userForm.roles} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-32">
{#each roles as role}
<option value={role.name}>{role.name}</option>
{/each}
</select>
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.roles_hint}</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md transition-colors">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminUsersPage:Component] -->

View File

@@ -0,0 +1,165 @@
<!-- [DEF:LoginPage:Component] -->
<!--
@SEMANTICS: login, auth, ui, form
@PURPOSE: Provides the user interface for local and ADFS authentication.
@LAYER: Feature
@RELATION: USES -> authStore
@RELATION: CALLS -> api.auth.login
@INVARIANT: Shows both local login form and ADFS SSO button.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
let username = '';
let password = '';
let error = '';
let loading = false;
// [DEF:handleLogin:Function]
/**
* @purpose Submits the local login form to the backend.
* @pre Username and password are not empty.
* @post User is authenticated and redirected on success.
*/
async function handleLogin() {
if (!username || !password) {
error = 'Please enter both username and password';
return;
}
loading = true;
error = '';
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (response.ok) {
const data = await response.json();
auth.setToken(data.access_token);
// Fetch user profile
const profileRes = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (profileRes.ok) {
const user = await profileRes.json();
auth.setUser(user);
goto('/');
} else {
error = 'Failed to fetch user profile';
}
} else {
const errData = await response.json();
error = errData.detail || 'Invalid username or password';
}
} catch (e) {
error = 'An error occurred during login';
console.error(e);
} finally {
loading = false;
}
}
// [/DEF:handleLogin:Function]
// [DEF:handleADFSLogin:Function]
/**
* @purpose Redirects the user to the ADFS login endpoint.
*/
function handleADFSLogin() {
window.location.href = '/api/auth/login/adfs';
}
// [/DEF:handleADFSLogin:Function]
onMount(() => {
if ($auth.isAuthenticated) {
goto('/');
}
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
{#if error}
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded border border-red-200">
{error}
</div>
{/if}
<form on:submit|preventDefault={handleLogin} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="username"
bind:value={username}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
bind:value={password}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div class="mt-6">
<button
on:click={handleADFSLogin}
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Corporate SSO (ADFS)
</button>
</div>
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* No additional styles needed, using Tailwind */
</style>
<!-- [/DEF:LoginPage:Component] -->

View File

@@ -8,9 +8,29 @@
/** @type {import('./$types').PageData} */ /** @type {import('./$types').PageData} */
export let data; export let data;
let settings = data.settings; let settings = data.settings || {
environments: [],
settings: {
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
}
};
$: settings = data.settings; $: if (data.settings) {
settings = { ...data.settings };
if (settings.settings && !settings.settings.storage) {
settings.settings.storage = {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
};
}
}
let newEnv = { let newEnv = {
id: '', id: '',

View File

@@ -18,7 +18,13 @@ export async function load() {
settings: { settings: {
environments: [], environments: [],
settings: { settings: {
default_environment_id: null default_environment_id: null,
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
} }
}, },
error: 'Failed to load settings' error: 'Failed to load settings'

View File

@@ -0,0 +1,240 @@
// [DEF:adminService:Module]
//
// @SEMANTICS: admin, users, roles, ad-mappings, api
// @PURPOSE: Service for Admin-related API calls (User and Role management).
// @LAYER: Service
// @RELATION: DEPENDS_ON -> frontend.src.lib.api
//
// @INVARIANT: All requests must include valid Admin JWT token (handled by api client).
// [SECTION: IMPORTS]
import { api } from '../lib/api';
// [/SECTION]
// [DEF:getUsers:Function]
/**
* @purpose Fetches all registered users from the backend.
* @pre User must be authenticated with Admin privileges.
* @post Returns an array of user objects.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_users
*/
async function getUsers() {
console.log('[getUsers][Entry]');
try {
const users = await api.requestApi('/admin/users', 'GET');
console.log('[getUsers][Coherence:OK]');
return users;
} catch (e) {
console.error('[getUsers][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getUsers:Function]
// [DEF:createUser:Function]
/**
* @purpose Creates a new local user.
* @pre User must be authenticated with Admin privileges.
* @param {Object} userData - User details (username, email, password, roles, is_active).
* @post New user record created in auth.db.
* @returns {Promise<Object>}
* @relation CALLS -> backend.src.api.routes.admin.create_user
*/
async function createUser(userData) {
console.log('[createUser][Entry]');
try {
const user = await api.postApi('/admin/users', userData);
console.log('[createUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[createUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createUser:Function]
// [DEF:getRoles:Function]
/**
* @purpose Fetches all available system roles.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_roles
*/
async function getRoles() {
console.log('[getRoles][Entry]');
try {
const roles = await api.requestApi('/admin/roles', 'GET');
console.log('[getRoles][Coherence:OK]');
return roles;
} catch (e) {
console.error('[getRoles][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getRoles:Function]
// [DEF:getADGroupMappings:Function]
/**
* @purpose Fetches mappings between AD groups and local roles.
* @returns {Promise<Array>}
*/
async function getADGroupMappings() {
console.log('[getADGroupMappings][Entry]');
try {
const mappings = await api.requestApi('/admin/ad-mappings', 'GET');
console.log('[getADGroupMappings][Coherence:OK]');
return mappings;
} catch (e) {
console.error('[getADGroupMappings][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getADGroupMappings:Function]
// [DEF:createADGroupMapping:Function]
/**
* @purpose Creates or updates an AD group to Role mapping.
* @param {Object} mappingData - Mapping details (ad_group, role_id).
* @returns {Promise<Object>}
*/
async function createADGroupMapping(mappingData) {
console.log('[createADGroupMapping][Entry]');
try {
const mapping = await api.postApi('/admin/ad-mappings', mappingData);
console.log('[createADGroupMapping][Coherence:OK]');
return mapping;
} catch (e) {
console.error('[createADGroupMapping][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createADGroupMapping:Function]
// [DEF:updateUser:Function]
/**
* @purpose Updates an existing user.
* @param {string} userId - Target user ID.
* @param {Object} userData - Updated user data.
* @returns {Promise<Object>}
*/
async function updateUser(userId, userData) {
console.log('[updateUser][Entry]');
try {
const user = await api.requestApi(`/admin/users/${userId}`, 'PUT', userData);
console.log('[updateUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[updateUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateUser:Function]
// [DEF:deleteUser:Function]
/**
* @purpose Deletes a user.
* @param {string} userId - Target user ID.
* @returns {Promise<void>}
*/
async function deleteUser(userId) {
console.log('[deleteUser][Entry]');
try {
await api.requestApi(`/admin/users/${userId}`, 'DELETE');
console.log('[deleteUser][Coherence:OK]');
} catch (e) {
console.error('[deleteUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteUser:Function]
// [DEF:createRole:Function]
/**
* @purpose Creates a new role.
* @param {Object} roleData - Role details (name, description, permissions).
* @returns {Promise<Object>}
*/
async function createRole(roleData) {
console.log('[createRole][Entry]');
try {
const role = await api.postApi('/admin/roles', roleData);
console.log('[createRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[createRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createRole:Function]
// [DEF:updateRole:Function]
/**
* @purpose Updates an existing role.
* @param {string} roleId - Target role ID.
* @param {Object} roleData - Updated role data.
* @returns {Promise<Object>}
*/
async function updateRole(roleId, roleData) {
console.log('[updateRole][Entry]');
try {
const role = await api.requestApi(`/admin/roles/${roleId}`, 'PUT', roleData);
console.log('[updateRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[updateRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateRole:Function]
// [DEF:deleteRole:Function]
/**
* @purpose Deletes a role.
* @param {string} roleId - Target role ID.
* @returns {Promise<void>}
*/
async function deleteRole(roleId) {
console.log('[deleteRole][Entry]');
try {
await api.requestApi(`/admin/roles/${roleId}`, 'DELETE');
console.log('[deleteRole][Coherence:OK]');
} catch (e) {
console.error('[deleteRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteRole:Function]
// [DEF:getPermissions:Function]
/**
* @purpose Fetches all available permissions.
* @returns {Promise<Array>}
*/
async function getPermissions() {
console.log('[getPermissions][Entry]');
try {
const permissions = await api.requestApi('/admin/permissions', 'GET');
console.log('[getPermissions][Coherence:OK]');
return permissions;
} catch (e) {
console.error('[getPermissions][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getPermissions:Function]
export const adminService = {
getUsers,
createUser,
updateUser,
deleteUser,
getRoles,
createRole,
updateRole,
deleteRole,
getPermissions,
getADGroupMappings,
createADGroupMapping
};
// [/DEF:adminService:Module]

View File

@@ -78,10 +78,15 @@ frontend/
├── src/ ├── src/
│ ├── lib/ │ ├── lib/
│ │ ├── auth/ # New: Frontend auth stores/logic │ │ ├── auth/ # New: Frontend auth stores/logic
│ │ └── api/ # Update: Add auth headers to requests │ │ └── api.js # Update: Add auth headers and export core methods
│ ├── services/
│ │ └── adminService.js # New: Service for admin API operations
│ ├── routes/ │ ├── routes/
│ │ ├── login/ # New: Login page │ │ ├── login/ # New: Login page
│ │ └── admin/ # New: Admin dashboard (Users/Roles) │ │ └── admin/
│ │ ├── users/ # New: User Management UI
│ │ ├── roles/ # New: Role Management UI
│ │ └── settings/ # New: ADFS Configuration UI
│ └── components/ │ └── components/
│ └── auth/ # New: Auth components (ProtectedRoute, Login form) │ └── auth/ # New: Auth components (ProtectedRoute, Login form)
``` ```

View File

@@ -49,6 +49,30 @@ As an administrator, I want to assign specific plugin access rights to users so
--- ---
### User Story 4 - Role Management (Priority: P1)
As an administrator, I want to create and manage roles with specific permissions so that I can easily assign standard access sets to users.
**Why this priority**: Essential for scalable user management. Assigning individual permissions to every user is tedious and error-prone.
**Acceptance Scenarios**:
1. **Given** an administrator, **When** they navigate to the Role Management page, **Then** they see a list of all system roles.
2. **Given** an administrator, **When** they create a new role "Auditor" with "READ" permission on "Logs", **Then** the role is saved and available for assignment.
3. **Given** an administrator, **When** they update a role's permissions, **Then** all users with that role effectively gain/lose those permissions.
**Why this priority**: Security is a core requirement. Without granular permissions, all authenticated users would have full administrative access, which defeats the purpose of multi-user support.
**Independent Test**: Create two users with different permissions (e.g., User A has access to "Backup", User B does not). Verify User A can access the Backup tool while User B receives a 403 Forbidden error.
**Acceptance Scenarios**:
1. **Given** a user with "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** the page loads successfully.
2. **Given** a user WITHOUT "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** they are denied access (UI hides the link, API returns 403).
3. **Given** an administrator, **When** they edit a user's permissions, **Then** the changes take effect immediately or upon next login.
---
### User Story 3 - ADFS Integration (Priority: P2) ### User Story 3 - ADFS Integration (Priority: P2)
As a corporate user, I want to log in using my organization's ADFS credentials so that I don't have to manage a separate password. As a corporate user, I want to log in using my organization's ADFS credentials so that I don't have to manage a separate password.
@@ -78,17 +102,18 @@ As a corporate user, I want to log in using my organization's ADFS credentials s
- **FR-001**: System MUST support local user authentication via username and password. - **FR-001**: System MUST support local user authentication via username and password.
- **FR-002**: System MUST support authentication via ADFS (Active Directory Federation Services) using standard federation protocols. - **FR-002**: System MUST support authentication via ADFS (Active Directory Federation Services) using standard federation protocols.
- **FR-003**: System MUST provide a mechanism to manage users (Create, Read, Update, Delete) - restricted to administrators. - **FR-003**: System MUST provide a web-based interface to manage users (Create, Read, Update, Delete) - restricted to administrators.
- **FR-004**: System MUST implement Role-Based Access Control (RBAC) where permissions are assigned to Roles, and Roles are assigned to Users. - **FR-004**: System MUST implement Role-Based Access Control (RBAC) where permissions are assigned to Roles, and Roles are assigned to Users.
- **FR-005**: System MUST enforce permissions at the server level for all plugin execution requests. - **FR-005**: System MUST enforce permissions at the server level for all plugin execution requests.
- **FR-006**: System MUST enforce permissions at the user interface level (hide navigation items/buttons for unauthorized plugins). - **FR-006**: System MUST enforce permissions at the user interface level (hide navigation items/buttons for unauthorized plugins).
- **FR-007**: System MUST securely store local user credentials. - **FR-007**: System MUST securely store local user credentials.
- **FR-008**: System MUST support Just-In-Time (JIT) provisioning for ADFS users ONLY if they belong to a mapped AD group. - **FR-008**: System MUST support Just-In-Time (JIT) provisioning for ADFS users ONLY if they belong to a mapped AD group.
- **FR-009**: System MUST provide a CLI utility to create an initial administrator account to prevent lockout during first deployment. - **FR-009**: System MUST provide a CLI utility to create an initial administrator account to prevent lockout during first deployment.
- **FR-010**: System MUST allow configuring mappings between Active Directory Groups and local System Roles. - **FR-010**: System MUST provide a web-based interface for configuring mappings between Active Directory Groups and local System Roles.
- **FR-011**: System MUST use JWT (JSON Web Tokens) for API session management. - **FR-011**: System MUST use JWT (JSON Web Tokens) for API session management.
- **FR-012**: System MUST persist authentication and authorization data in a dedicated SQLite database (`auth.db`). - **FR-012**: System MUST persist authentication and authorization data in a dedicated SQLite database (`auth.db`).
- **FR-013**: System MUST provide a unified login interface supporting both Local (Username/Password) and ADFS (SSO Button) authentication methods simultaneously. - **FR-013**: System MUST provide a unified login interface supporting both Local (Username/Password) and ADFS (SSO Button) authentication methods simultaneously.
- **FR-014**: System MUST provide a web-based interface to manage Roles (Create, Update, Delete) and assign permissions to them.
### Key Entities ### Key Entities

View File

@@ -8,74 +8,81 @@
*Goal: Initialize the auth database, core dependencies, and backend infrastructure.* *Goal: Initialize the auth database, core dependencies, and backend infrastructure.*
- [ ] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt` - [x] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt`
- [ ] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py` - [x] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py`
- [ ] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py` - [x] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py`
- [ ] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py` - [x] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py`
- [ ] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py` - [x] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py`
- [ ] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py` - [x] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py`
- [ ] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.py` - [x] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.py`
- [ ] T008 [P] Implement CLI tool for creating the initial admin user in `backend/src/scripts/create_admin.py` - [x] T008 [P] Implement CLI tool for creating the initial admin user in `backend/src/scripts/create_admin.py`
## Phase 2: User Story 1 - Local User Authentication (Priority: P1) ## Phase 2: User Story 1 - Local User Authentication (Priority: P1)
*Goal: Enable users to log in with username/password and receive a JWT session.* *Goal: Enable users to log in with username/password and receive a JWT session.*
- [ ] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py` - [x] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py`
- [ ] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py` - [x] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py`
- [ ] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py` - [x] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py`
- [ ] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py` - [x] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py`
- [ ] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py` - [x] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py`
- [ ] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py` - [x] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py`
- [ ] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py` - [x] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py`
- [ ] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py` - [x] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py`
- [ ] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts` - [x] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts`
- [ ] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte` - [x] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte`
- [ ] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte` - [x] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte`
- [ ] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte` - [x] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte`
- [ ] T037 [US1] Implement password complexity validation logic in `backend/src/core/auth/security.py` - [x] T037 [US1] Implement password complexity validation logic in `backend/src/core/auth/security.py`
## Phase 3: User Story 2 - Plugin-Based Access Control (Priority: P1) ## Phase 3: User Story 2 - Plugin-Based Access Control (Priority: P1)
*Goal: Restrict access to plugins based on user roles and permissions.* *Goal: Restrict access to plugins based on user roles and permissions.*
- [ ] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py` - [x] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py`
- [ ] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py` - [x] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py`
- [ ] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py` - [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
- [ ] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py` - [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
- [ ] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` (with pagination) in `backend/src/api/routes/admin.py` - [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py`
- [ ] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte` - [ ] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py`
- [ ] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte` - [ ] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py`
- [ ] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte` - [x] T051 [US2] Implement `adminService.js` for frontend API orchestration
- [ ] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py` - [ ] T055 [US2] Update `adminService.js` with new CRUD methods
- [x] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
- [ ] T056 [US2] Update Admin User Dashboard to support Edit/Delete operations in `frontend/src/routes/admin/users/+page.svelte`
- [ ] T057 [US4] Create Role Management UI in `frontend/src/routes/admin/roles/+page.svelte`
- [x] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
- [x] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
- [x] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`
## Phase 4: User Story 3 - ADFS Integration (Priority: P2) ## Phase 4: User Story 3 - ADFS Integration (Priority: P2)
*Goal: Enable corporate SSO login via ADFS and JIT provisioning.* *Goal: Enable corporate SSO login via ADFS and JIT provisioning.*
- [ ] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py` - [x] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py`
- [ ] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script - [x] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script
- [ ] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py` - [x] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py`
- [ ] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py` - [x] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py`
- [ ] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte` - [x] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte`
- [ ] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte` - [x] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte`
- [ ] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py` - [x] T052 [US3] Extend Admin API with AD mapping endpoints in `backend/src/api/routes/admin.py`
- [ ] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py` - [x] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py`
- [x] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py`
## Phase 5: Polish & Security Hardening ## Phase 5: Polish & Security Hardening
*Goal: Ensure security best practices and smooth UX.* *Goal: Ensure security best practices and smooth UX.*
- [ ] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py` - [x] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py`
- [ ] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py` - [x] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py`
- [ ] T034 Verify error messages are generic (no username enumeration) across all auth endpoints - [x] T034 Verify error messages are generic (no username enumeration) across all auth endpoints
- [ ] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts` - [x] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts`
- [ ] T036 Final manual test of switching between Local and ADFS login flows - [x] T036 Final manual test of switching between Local and ADFS login flows
- [ ] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte` - [x] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte`
- [ ] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py` - [x] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py`
- [ ] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components - [x] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components
- [ ] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/` - [x] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/`
- [ ] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts` - [x] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
## Dependencies ## Dependencies