Передаем на тест
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,3 +67,4 @@ backend/mappings.db
|
||||
|
||||
backend/tasks.db
|
||||
backend/logs
|
||||
backend/auth.db
|
||||
|
||||
4
.kilocode/workflows/read_semantic.md
Normal file
4
.kilocode/workflows/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
@@ -1,8 +1,8 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
Version: 1.8.0 (Frontend Unification)
|
||||
Version: 1.9.0 (Security & RBAC Mandate)
|
||||
Changes:
|
||||
- Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n).
|
||||
- Added Principle IX: Security & Access Control (Mandating granular permissions for plugins).
|
||||
Templates Status:
|
||||
- .specify/templates/plan-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.
|
||||
- **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
|
||||
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
|
||||
- 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.
|
||||
- **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
BIN
backend/backend/auth.db
Normal file
Binary file not shown.
Binary file not shown.
@@ -25,9 +25,13 @@ keyring==25.7.0
|
||||
more-itertools==10.8.0
|
||||
pycparser==2.23
|
||||
pydantic==2.12.5
|
||||
pydantic-settings
|
||||
pydantic_core==2.41.5
|
||||
python-multipart==0.0.21
|
||||
PyYAML==6.0.3
|
||||
passlib[bcrypt]
|
||||
python-jose[cryptography]
|
||||
PyJWT
|
||||
RapidFuzz==3.14.3
|
||||
referencing==0.37.0
|
||||
requests==2.32.5
|
||||
@@ -44,4 +48,6 @@ websockets==15.0.1
|
||||
pandas
|
||||
psycopg2-binary
|
||||
openpyxl
|
||||
GitPython==3.1.44
|
||||
GitPython==3.1.44
|
||||
itsdangerous
|
||||
email-validator
|
||||
@@ -1,59 +1,108 @@
|
||||
# [DEF:AuthModule:Module]
|
||||
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
|
||||
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Used by API routers to protect endpoints that require authentication.
|
||||
# [DEF:backend.src.api.auth:Module]
|
||||
#
|
||||
# @SEMANTICS: api, auth, routes, login, logout
|
||||
# @PURPOSE: Authentication API endpoints.
|
||||
# @LAYER: API
|
||||
# @RELATION: USES -> backend.src.services.auth_service.AuthService
|
||||
# @RELATION: USES -> backend.src.core.database.get_auth_db
|
||||
#
|
||||
# @INVARIANT: All auth endpoints must return consistent error codes.
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from starlette.config import Config
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from ..core.database import get_auth_db
|
||||
from ..services.auth_service import AuthService
|
||||
from ..schemas.auth import Token, User as UserSchema
|
||||
from ..dependencies import get_current_user
|
||||
from ..core.auth.oauth import oauth
|
||||
from ..core.auth.logger import log_security_event
|
||||
from ..core.logger import belief_scope
|
||||
import starlette.requests
|
||||
# [/SECTION]
|
||||
|
||||
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
|
||||
# Create an in-memory .env file
|
||||
from io import StringIO
|
||||
config_data = StringIO("""
|
||||
ADFS_CLIENT_ID=your-client-id
|
||||
ADFS_CLIENT_SECRET=your-client-secret
|
||||
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
|
||||
""")
|
||||
config = Config(config_data)
|
||||
oauth = OAuth(config)
|
||||
# [DEF:router:Variable]
|
||||
# @PURPOSE: APIRouter instance for authentication routes.
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
# [/DEF:router:Variable]
|
||||
|
||||
oauth.register(
|
||||
name='adfs',
|
||||
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
|
||||
client_kwargs={'scope': 'openid profile email'}
|
||||
)
|
||||
# [DEF:login_for_access_token:Function]
|
||||
# @PURPOSE: Authenticates a user and returns a JWT access token.
|
||||
# @PRE: form_data contains username and password.
|
||||
# @POST: Returns a Token object on success.
|
||||
# @THROW: HTTPException 401 if authentication fails.
|
||||
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: Token - The generated JWT token.
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_auth_db)
|
||||
):
|
||||
with belief_scope("api.auth.login"):
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
|
||||
return auth_service.create_session(user)
|
||||
# [/DEF:login_for_access_token:Function]
|
||||
|
||||
oauth2_scheme = OAuth2AuthorizationCodeBearer(
|
||||
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
|
||||
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
|
||||
)
|
||||
# [DEF:read_users_me:Function]
|
||||
# @PURPOSE: Retrieves the profile of the currently authenticated user.
|
||||
# @PRE: Valid JWT token provided.
|
||||
# @POST: Returns the current user's data.
|
||||
# @PARAM: current_user (UserSchema) - The user extracted from the token.
|
||||
# @RETURN: UserSchema - The current user profile.
|
||||
@router.get("/me", response_model=UserSchema)
|
||||
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||
with belief_scope("api.auth.me"):
|
||||
return current_user
|
||||
# [/DEF:read_users_me:Function]
|
||||
|
||||
# [DEF:get_current_user:Function]
|
||||
# @PURPOSE: Dependency to get the current user from the ADFS token.
|
||||
# @PARAM: token (str) - The OAuth2 bearer token.
|
||||
# @PRE: token should be provided via Authorization header.
|
||||
# @POST: Returns user details if authenticated, else raises 401.
|
||||
# @RETURN: Dict[str, str] - User information.
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
"""
|
||||
Dependency to get the current user from the ADFS token.
|
||||
This is a placeholder and needs to be fully implemented.
|
||||
"""
|
||||
# In a real implementation, you would:
|
||||
# 1. Validate the token with ADFS.
|
||||
# 2. Fetch user information.
|
||||
# 3. Create a user object.
|
||||
# For now, we'll just check if a token exists.
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
# A real implementation would return a user object.
|
||||
return {"placeholder_user": "user@example.com"}
|
||||
# [/DEF:get_current_user:Function]
|
||||
# [/DEF:AuthModule:Module]
|
||||
# [DEF:logout:Function]
|
||||
# @PURPOSE: Logs out the current user (placeholder for session revocation).
|
||||
# @PRE: Valid JWT token provided.
|
||||
# @POST: Returns success message.
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: UserSchema = Depends(get_current_user)):
|
||||
with belief_scope("api.auth.logout"):
|
||||
log_security_event("LOGOUT", current_user.username)
|
||||
# In a stateless JWT setup, client-side token deletion is primary.
|
||||
# Server-side revocation (blacklisting) can be added here if needed.
|
||||
return {"message": "Successfully logged out"}
|
||||
# [/DEF:logout:Function]
|
||||
|
||||
# [DEF:login_adfs:Function]
|
||||
# @PURPOSE: Initiates the ADFS OIDC login flow.
|
||||
# @POST: Redirects the user to ADFS.
|
||||
@router.get("/login/adfs")
|
||||
async def login_adfs(request: starlette.requests.Request):
|
||||
with belief_scope("api.auth.login_adfs"):
|
||||
redirect_uri = request.url_for('auth_callback_adfs')
|
||||
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
|
||||
# [/DEF:login_adfs:Function]
|
||||
|
||||
# [DEF:auth_callback_adfs:Function]
|
||||
# @PURPOSE: Handles the callback from ADFS after successful authentication.
|
||||
# @POST: Provisions user JIT and returns session token.
|
||||
@router.get("/callback/adfs", name="auth_callback_adfs")
|
||||
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
|
||||
with belief_scope("api.auth.callback_adfs"):
|
||||
token = await oauth.adfs.authorize_access_token(request)
|
||||
user_info = token.get('userinfo')
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
|
||||
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.provision_adfs_user(user_info)
|
||||
return auth_service.create_session(user)
|
||||
# [/DEF:auth_callback_adfs:Function]
|
||||
|
||||
# [/DEF:backend.src.api.auth:Module]
|
||||
@@ -1 +1 @@
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin
|
||||
|
||||
305
backend/src/api/routes/admin.py
Normal file
305
backend/src/api/routes/admin.py
Normal file
@@ -0,0 +1,305 @@
|
||||
# [DEF:backend.src.api.routes.admin:Module]
|
||||
#
|
||||
# @SEMANTICS: api, admin, users, roles, permissions
|
||||
# @PURPOSE: Admin API endpoints for user and role management.
|
||||
# @LAYER: API
|
||||
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
|
||||
# @RELATION: USES -> backend.src.dependencies.has_permission
|
||||
#
|
||||
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from ...core.database import get_auth_db
|
||||
from ...core.auth.repository import AuthRepository
|
||||
from ...core.auth.security import get_password_hash
|
||||
from ...schemas.auth import (
|
||||
User as UserSchema, UserCreate, UserUpdate,
|
||||
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
|
||||
ADGroupMappingSchema, ADGroupMappingCreate
|
||||
)
|
||||
from ...models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ...dependencies import has_permission, get_current_user
|
||||
from ...core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:router:Variable]
|
||||
# @PURPOSE: APIRouter instance for admin routes.
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
# [/DEF:router:Variable]
|
||||
|
||||
# [DEF:list_users:Function]
|
||||
# @PURPOSE: Lists all registered users.
|
||||
# @PRE: Current user has 'Admin' role.
|
||||
# @POST: Returns a list of UserSchema objects.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: List[UserSchema] - List of users.
|
||||
@router.get("/users", response_model=List[UserSchema])
|
||||
async def list_users(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_users"):
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
# [/DEF:list_users:Function]
|
||||
|
||||
# [DEF:create_user:Function]
|
||||
# @PURPOSE: Creates a new local user.
|
||||
# @PRE: Current user has 'Admin' role.
|
||||
# @POST: New user is created in the database.
|
||||
# @PARAM: user_in (UserCreate) - New user data.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: UserSchema - The created user.
|
||||
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.create_user"):
|
||||
repo = AuthRepository(db)
|
||||
if repo.get_user_by_username(user_in.username):
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
new_user = User(
|
||||
username=user_in.username,
|
||||
email=user_in.email,
|
||||
password_hash=get_password_hash(user_in.password),
|
||||
auth_source="LOCAL",
|
||||
is_active=user_in.is_active
|
||||
)
|
||||
|
||||
for role_name in user_in.roles:
|
||||
role = repo.get_role_by_name(role_name)
|
||||
if role:
|
||||
new_user.roles.append(role)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
# [/DEF:create_user:Function]
|
||||
|
||||
# [DEF:update_user:Function]
|
||||
# @PURPOSE: Updates an existing user.
|
||||
@router.put("/users/{user_id}", response_model=UserSchema)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_in: UserUpdate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.update_user"):
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_in.email is not None:
|
||||
user.email = user_in.email
|
||||
if user_in.is_active is not None:
|
||||
user.is_active = user_in.is_active
|
||||
if user_in.password is not None:
|
||||
user.password_hash = get_password_hash(user_in.password)
|
||||
|
||||
if user_in.roles is not None:
|
||||
user.roles = []
|
||||
for role_name in user_in.roles:
|
||||
role = repo.get_role_by_name(role_name)
|
||||
if role:
|
||||
user.roles.append(role)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
# [/DEF:update_user:Function]
|
||||
|
||||
# [DEF:delete_user:Function]
|
||||
# @PURPOSE: Deletes a user.
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.delete_user"):
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return None
|
||||
# [/DEF:delete_user:Function]
|
||||
|
||||
# [DEF:list_roles:Function]
|
||||
# @PURPOSE: Lists all available roles.
|
||||
# @RETURN: List[RoleSchema] - List of roles.
|
||||
# @RELATION: CALLS -> backend.src.models.auth.Role
|
||||
@router.get("/roles", response_model=List[RoleSchema])
|
||||
async def list_roles(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_roles"):
|
||||
return db.query(Role).all()
|
||||
# [/DEF:list_roles:Function]
|
||||
|
||||
# [DEF:create_role:Function]
|
||||
# @PURPOSE: Creates a new system role with associated permissions.
|
||||
# @PRE: Role name must be unique.
|
||||
# @POST: New Role record is created in auth.db.
|
||||
# @PARAM: role_in (RoleCreate) - New role data.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: RoleSchema - The created role.
|
||||
# @SIDE_EFFECT: Commits new role and associations to auth.db.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
|
||||
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
role_in: RoleCreate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.create_role"):
|
||||
if db.query(Role).filter(Role.name == role_in.name).first():
|
||||
raise HTTPException(status_code=400, detail="Role already exists")
|
||||
|
||||
new_role = Role(name=role_in.name, description=role_in.description)
|
||||
repo = AuthRepository(db)
|
||||
|
||||
for perm_id_or_str in role_in.permissions:
|
||||
perm = repo.get_permission_by_id(perm_id_or_str)
|
||||
if not perm and ":" in perm_id_or_str:
|
||||
res, act = perm_id_or_str.split(":", 1)
|
||||
perm = repo.get_permission_by_resource_action(res, act)
|
||||
|
||||
if perm:
|
||||
new_role.permissions.append(perm)
|
||||
|
||||
db.add(new_role)
|
||||
db.commit()
|
||||
db.refresh(new_role)
|
||||
return new_role
|
||||
# [/DEF:create_role:Function]
|
||||
|
||||
# [DEF:update_role:Function]
|
||||
# @PURPOSE: Updates an existing role's metadata and permissions.
|
||||
# @PRE: role_id must be a valid existing role UUID.
|
||||
# @POST: Role record is updated in auth.db.
|
||||
# @PARAM: role_id (str) - Target role identifier.
|
||||
# @PARAM: role_in (RoleUpdate) - Updated role data.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: RoleSchema - The updated role.
|
||||
# @SIDE_EFFECT: Commits updates to auth.db.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
|
||||
@router.put("/roles/{role_id}", response_model=RoleSchema)
|
||||
async def update_role(
|
||||
role_id: str,
|
||||
role_in: RoleUpdate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.update_role"):
|
||||
repo = AuthRepository(db)
|
||||
role = repo.get_role_by_id(role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
if role_in.name is not None:
|
||||
role.name = role_in.name
|
||||
if role_in.description is not None:
|
||||
role.description = role_in.description
|
||||
|
||||
if role_in.permissions is not None:
|
||||
role.permissions = []
|
||||
for perm_id_or_str in role_in.permissions:
|
||||
perm = repo.get_permission_by_id(perm_id_or_str)
|
||||
if not perm and ":" in perm_id_or_str:
|
||||
res, act = perm_id_or_str.split(":", 1)
|
||||
perm = repo.get_permission_by_resource_action(res, act)
|
||||
|
||||
if perm:
|
||||
role.permissions.append(perm)
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
# [/DEF:update_role:Function]
|
||||
|
||||
# [DEF:delete_role:Function]
|
||||
# @PURPOSE: Removes a role from the system.
|
||||
# @PRE: role_id must be a valid existing role UUID.
|
||||
# @POST: Role record is removed from auth.db.
|
||||
# @PARAM: role_id (str) - Target role identifier.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: None
|
||||
# @SIDE_EFFECT: Deletes record from auth.db and commits.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
|
||||
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_role(
|
||||
role_id: str,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.delete_role"):
|
||||
repo = AuthRepository(db)
|
||||
role = repo.get_role_by_id(role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
return None
|
||||
# [/DEF:delete_role:Function]
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @PURPOSE: Lists all available system permissions for assignment.
|
||||
# @POST: Returns a list of all PermissionSchema objects.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: List[PermissionSchema] - List of permissions.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
|
||||
@router.get("/permissions", response_model=List[PermissionSchema])
|
||||
async def list_permissions(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_permissions"):
|
||||
repo = AuthRepository(db)
|
||||
return repo.list_permissions()
|
||||
# [/DEF:list_permissions:Function]
|
||||
|
||||
# [DEF:list_ad_mappings:Function]
|
||||
# @PURPOSE: Lists all AD Group to Role mappings.
|
||||
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
|
||||
async def list_ad_mappings(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_ad_mappings"):
|
||||
return db.query(ADGroupMapping).all()
|
||||
# [/DEF:list_ad_mappings:Function]
|
||||
|
||||
# [DEF:create_ad_mapping:Function]
|
||||
# @PURPOSE: Creates a new AD Group mapping.
|
||||
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
|
||||
async def create_ad_mapping(
|
||||
mapping_in: ADGroupMappingCreate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("api.admin.create_ad_mapping"):
|
||||
new_mapping = ADGroupMapping(
|
||||
ad_group=mapping_in.ad_group,
|
||||
role_id=mapping_in.role_id
|
||||
)
|
||||
db.add(new_mapping)
|
||||
db.commit()
|
||||
db.refresh(new_mapping)
|
||||
return new_mapping
|
||||
# [/DEF:create_ad_mapping:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.admin:Module]
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
@@ -29,7 +29,10 @@ router = APIRouter()
|
||||
# @POST: Returns masked AppConfig.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
@router.get("", response_model=AppConfig)
|
||||
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
async def get_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_settings"):
|
||||
logger.info("[get_settings][Entry] Fetching all settings")
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
@@ -49,7 +52,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
|
||||
@router.patch("/global", response_model=GlobalSettings)
|
||||
async def update_global_settings(
|
||||
settings: GlobalSettings,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info("[update_global_settings][Entry] Updating global settings")
|
||||
@@ -62,7 +66,10 @@ async def update_global_settings(
|
||||
# @PURPOSE: Retrieves storage-specific settings.
|
||||
# @RETURN: StorageConfig - The storage configuration.
|
||||
@router.get("/storage", response_model=StorageConfig)
|
||||
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
async def get_storage_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_storage_settings"):
|
||||
return config_manager.get_config().settings.storage
|
||||
# [/DEF:get_storage_settings:Function]
|
||||
@@ -73,7 +80,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
|
||||
# @POST: Storage settings are updated and saved.
|
||||
# @RETURN: StorageConfig - The updated storage settings.
|
||||
@router.put("/storage", response_model=StorageConfig)
|
||||
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
async def update_storage_settings(
|
||||
storage: StorageConfig,
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_storage_settings"):
|
||||
is_valid, message = config_manager.validate_path(storage.root_path)
|
||||
if not is_valid:
|
||||
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
|
||||
# @POST: Returns list of environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
@router.get("/environments", response_model=List[Environment])
|
||||
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
async def get_environments(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_environments"):
|
||||
logger.info("[get_environments][Entry] Fetching environments")
|
||||
return config_manager.get_environments()
|
||||
@@ -106,7 +120,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
|
||||
@router.post("/environments", response_model=Environment)
|
||||
async def add_environment(
|
||||
env: Environment,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
|
||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
from ...dependencies import get_task_manager
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
|
||||
# @RETURN: Task - The created task instance.
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
|
||||
):
|
||||
"""
|
||||
Create and start a new task for a given plugin.
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -18,7 +19,8 @@ import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
|
||||
from .api import auth
|
||||
from .core.database import init_db
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -55,6 +57,10 @@ async def shutdown_event():
|
||||
scheduler.stop()
|
||||
# [/DEF:shutdown_event:Function]
|
||||
|
||||
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
||||
from .core.auth.config import auth_config
|
||||
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
|
||||
# [/DEF:log_requests:Function]
|
||||
|
||||
# Include API routes
|
||||
app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
|
||||
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
|
||||
45
backend/src/core/auth/config.py
Normal file
45
backend/src/core/auth/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# [DEF:backend.src.core.auth.config:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, config, settings, jwt, adfs
|
||||
# @PURPOSE: Centralized configuration for authentication and authorization.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> pydantic
|
||||
#
|
||||
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthConfig:Class]
|
||||
# @PURPOSE: Holds authentication-related settings.
|
||||
# @PRE: Environment variables may be provided via .env file.
|
||||
# @POST: Returns a configuration object with validated settings.
|
||||
class AuthConfig(BaseSettings):
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Database Settings
|
||||
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
|
||||
|
||||
# ADFS Settings
|
||||
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
|
||||
ADFS_CLIENT_SECRET: str = Field(default="", env="ADFS_CLIENT_SECRET")
|
||||
ADFS_METADATA_URL: str = Field(default="", env="ADFS_METADATA_URL")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
# [/DEF:AuthConfig:Class]
|
||||
|
||||
# [DEF:auth_config:Variable]
|
||||
# @PURPOSE: Singleton instance of AuthConfig.
|
||||
auth_config = AuthConfig()
|
||||
# [/DEF:auth_config:Variable]
|
||||
|
||||
# [/DEF:backend.src.core.auth.config:Module]
|
||||
54
backend/src/core/auth/jwt.py
Normal file
54
backend/src/core/auth/jwt.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# [DEF:backend.src.core.auth.jwt:Module]
|
||||
#
|
||||
# @SEMANTICS: jwt, token, session, auth
|
||||
# @PURPOSE: JWT token generation and validation logic.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> jose
|
||||
# @RELATION: USES -> backend.src.core.auth.config.auth_config
|
||||
#
|
||||
# @INVARIANT: Tokens must include expiration time and user identifier.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from jose import JWTError, jwt
|
||||
from .config import auth_config
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:create_access_token:Function]
|
||||
# @PURPOSE: Generates a new JWT access token.
|
||||
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
|
||||
# @POST: Returns a signed JWT string.
|
||||
#
|
||||
# @PARAM: data (dict) - Payload data for the token.
|
||||
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
|
||||
# @RETURN: str - The encoded JWT.
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
with belief_scope("create_access_token"):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
|
||||
return encoded_jwt
|
||||
# [/DEF:create_access_token:Function]
|
||||
|
||||
# [DEF:decode_token:Function]
|
||||
# @PURPOSE: Decodes and validates a JWT token.
|
||||
# @PRE: token is a signed JWT string.
|
||||
# @POST: Returns the decoded payload if valid.
|
||||
#
|
||||
# @PARAM: token (str) - The JWT to decode.
|
||||
# @RETURN: dict - The decoded payload.
|
||||
# @THROW: jose.JWTError - If token is invalid or expired.
|
||||
def decode_token(token: str) -> dict:
|
||||
with belief_scope("decode_token"):
|
||||
payload = jwt.decode(token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM])
|
||||
return payload
|
||||
# [/DEF:decode_token:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.jwt:Module]
|
||||
31
backend/src/core/auth/logger.py
Normal file
31
backend/src/core/auth/logger.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# [DEF:backend.src.core.auth.logger:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, logger, audit, security
|
||||
# @PURPOSE: Audit logging for security-related events.
|
||||
# @LAYER: Core
|
||||
# @RELATION: USES -> backend.src.core.logger.belief_scope
|
||||
#
|
||||
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from ..logger import logger, belief_scope
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:log_security_event:Function]
|
||||
# @PURPOSE: Logs a security-related event for audit trails.
|
||||
# @PRE: event_type and username are strings.
|
||||
# @POST: Security event is written to the application log.
|
||||
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
|
||||
# @PARAM: username (str) - The user involved in the event.
|
||||
# @PARAM: details (dict) - Additional non-sensitive metadata.
|
||||
def log_security_event(event_type: str, username: str, details: dict = None):
|
||||
with belief_scope("log_security_event", f"{event_type}:{username}"):
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
msg = f"[AUDIT][{timestamp}][{event_type}] User: {username}"
|
||||
if details:
|
||||
msg += f" Details: {details}"
|
||||
logger.info(msg)
|
||||
# [/DEF:log_security_event:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.logger:Module]
|
||||
41
backend/src/core/auth/oauth.py
Normal file
41
backend/src/core/auth/oauth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# [DEF:backend.src.core.auth.oauth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, oauth, oidc, adfs
|
||||
# @PURPOSE: ADFS OIDC configuration and client using Authlib.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> authlib
|
||||
# @RELATION: USES -> backend.src.core.auth.config.auth_config
|
||||
#
|
||||
# @INVARIANT: Must use secure OIDC flows.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from .config import auth_config
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:oauth:Variable]
|
||||
# @PURPOSE: Global Authlib OAuth registry.
|
||||
oauth = OAuth()
|
||||
# [/DEF:oauth:Variable]
|
||||
|
||||
# [DEF:register_adfs:Function]
|
||||
# @PURPOSE: Registers the ADFS OIDC client.
|
||||
# @PRE: ADFS configuration is provided in auth_config.
|
||||
# @POST: ADFS client is registered in oauth registry.
|
||||
def register_adfs():
|
||||
if auth_config.ADFS_CLIENT_ID:
|
||||
oauth.register(
|
||||
name='adfs',
|
||||
client_id=auth_config.ADFS_CLIENT_ID,
|
||||
client_secret=auth_config.ADFS_CLIENT_SECRET,
|
||||
server_metadata_url=auth_config.ADFS_METADATA_URL,
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile groups'
|
||||
}
|
||||
)
|
||||
# [/DEF:register_adfs:Function]
|
||||
|
||||
# Initial registration
|
||||
register_adfs()
|
||||
|
||||
# [/DEF:backend.src.core.auth.oauth:Module]
|
||||
123
backend/src/core/auth/repository.py
Normal file
123
backend/src/core/auth/repository.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# [DEF:backend.src.core.auth.repository:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, repository, database, user, role
|
||||
# @PURPOSE: Data access layer for authentication-related entities.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.auth
|
||||
#
|
||||
# @INVARIANT: All database operations must be performed within a session.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthRepository:Class]
|
||||
# @PURPOSE: Encapsulates database operations for authentication.
|
||||
class AuthRepository:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the repository with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_user_by_username:Function]
|
||||
# @PURPOSE: Retrieves a user by their username.
|
||||
# @PRE: username is a string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: username (str) - The username to search for.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_username"):
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
# [/DEF:get_user_by_username:Function]
|
||||
|
||||
# [DEF:get_user_by_id:Function]
|
||||
# @PURPOSE: Retrieves a user by their unique ID.
|
||||
# @PRE: user_id is a valid UUID string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: user_id (str) - The user's unique identifier.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_id"):
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
# [/DEF:get_user_by_id:Function]
|
||||
|
||||
# [DEF:get_role_by_name:Function]
|
||||
# @PURPOSE: Retrieves a role by its name.
|
||||
# @PRE: name is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: name (str) - The role name to search for.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
def get_role_by_name(self, name: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_name"):
|
||||
return self.db.query(Role).filter(Role.name == name).first()
|
||||
# [/DEF:get_role_by_name:Function]
|
||||
|
||||
# [DEF:update_last_login:Function]
|
||||
# @PURPOSE: Updates the last_login timestamp for a user.
|
||||
# @PRE: user object is a valid User instance.
|
||||
# @POST: User's last_login is updated in the database.
|
||||
# @SIDE_EFFECT: Commits the transaction.
|
||||
# @PARAM: user (User) - The user to update.
|
||||
def update_last_login(self, user: User):
|
||||
with belief_scope("AuthRepository.update_last_login"):
|
||||
from datetime import datetime
|
||||
user.last_login = datetime.utcnow()
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
# [/DEF:update_last_login:Function]
|
||||
|
||||
# [DEF:get_role_by_id:Function]
|
||||
# @PURPOSE: Retrieves a role by its unique ID.
|
||||
# @PRE: role_id is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: role_id (str) - The role's unique identifier.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
def get_role_by_id(self, role_id: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_id"):
|
||||
return self.db.query(Role).filter(Role.id == role_id).first()
|
||||
# [/DEF:get_role_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_id:Function]
|
||||
# @PURPOSE: Retrieves a permission by its unique ID.
|
||||
# @PRE: perm_id is a string.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: perm_id (str) - The permission's unique identifier.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_id"):
|
||||
return self.db.query(Permission).filter(Permission.id == perm_id).first()
|
||||
# [/DEF:get_permission_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_resource_action:Function]
|
||||
# @PURPOSE: Retrieves a permission by resource and action.
|
||||
# @PRE: resource and action are strings.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: resource (str) - The resource name.
|
||||
# @PARAM: action (str) - The action name.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_resource_action"):
|
||||
return self.db.query(Permission).filter(
|
||||
Permission.resource == resource,
|
||||
Permission.action == action
|
||||
).first()
|
||||
# [/DEF:get_permission_by_resource_action:Function]
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @PURPOSE: Lists all available permissions.
|
||||
# @POST: Returns a list of all Permission objects.
|
||||
# @RETURN: List[Permission] - List of permissions.
|
||||
def list_permissions(self) -> List[Permission]:
|
||||
with belief_scope("AuthRepository.list_permissions"):
|
||||
return self.db.query(Permission).all()
|
||||
# [/DEF:list_permissions:Function]
|
||||
|
||||
# [/DEF:AuthRepository:Class]
|
||||
|
||||
# [/DEF:backend.src.core.auth.repository:Module]
|
||||
42
backend/src/core/auth/security.py
Normal file
42
backend/src/core/auth/security.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# [DEF:backend.src.core.auth.security:Module]
|
||||
#
|
||||
# @SEMANTICS: security, password, hashing, bcrypt
|
||||
# @PURPOSE: Utility for password hashing and verification using Passlib.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> passlib
|
||||
#
|
||||
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from passlib.context import CryptContext
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:pwd_context:Variable]
|
||||
# @PURPOSE: Passlib CryptContext for password management.
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# [/DEF:pwd_context:Variable]
|
||||
|
||||
# [DEF:verify_password:Function]
|
||||
# @PURPOSE: Verifies a plain password against a hashed password.
|
||||
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
|
||||
# @POST: Returns True if password matches, False otherwise.
|
||||
#
|
||||
# @PARAM: plain_password (str) - The unhashed password.
|
||||
# @PARAM: hashed_password (str) - The stored hash.
|
||||
# @RETURN: bool - Verification result.
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
# [/DEF:verify_password:Function]
|
||||
|
||||
# [DEF:get_password_hash:Function]
|
||||
# @PURPOSE: Generates a bcrypt hash for a plain password.
|
||||
# @PRE: password is a string.
|
||||
# @POST: Returns a secure bcrypt hash string.
|
||||
#
|
||||
# @PARAM: password (str) - The password to hash.
|
||||
# @RETURN: str - The generated hash.
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
# [/DEF:get_password_hash:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
@@ -5,6 +5,7 @@
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.mapping
|
||||
# @RELATION: USES -> backend.src.core.auth.config
|
||||
#
|
||||
# @INVARIANT: A single engine instance is used for the entire application.
|
||||
|
||||
@@ -16,44 +17,70 @@ from ..models.mapping import Base
|
||||
from ..models.task import TaskRecord
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||
from ..models.auth import User, Role, Permission, ADGroupMapping
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the main mappings database.
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
||||
# [/DEF:DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the tasks execution database.
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
||||
# [/DEF:TASKS_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:AUTH_DATABASE_URL:Constant]
|
||||
# @PURPOSE: URL for the authentication database.
|
||||
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
|
||||
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||
|
||||
# [DEF:engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for mappings database.
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:engine:Variable]
|
||||
|
||||
# [DEF:tasks_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for tasks database.
|
||||
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:tasks_engine:Variable]
|
||||
|
||||
# [DEF:auth_engine:Variable]
|
||||
# @PURPOSE: SQLAlchemy engine for authentication database.
|
||||
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# [/DEF:auth_engine:Variable]
|
||||
|
||||
# [DEF:SessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the main mappings database.
|
||||
# @PRE: engine is initialized.
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# [/DEF:SessionLocal:Class]
|
||||
|
||||
# [DEF:TasksSessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the tasks execution database.
|
||||
# @PRE: tasks_engine is initialized.
|
||||
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||
# [/DEF:TasksSessionLocal:Class]
|
||||
|
||||
# [DEF:AuthSessionLocal:Class]
|
||||
# @PURPOSE: A session factory for the authentication database.
|
||||
# @PRE: auth_engine is initialized.
|
||||
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||
# [/DEF:AuthSessionLocal:Class]
|
||||
|
||||
# [DEF:init_db:Function]
|
||||
# @PURPOSE: Initializes the database by creating all tables.
|
||||
# @PRE: engine and tasks_engine are initialized.
|
||||
# @POST: Database tables created.
|
||||
# @PRE: engine, tasks_engine and auth_engine are initialized.
|
||||
# @POST: Database tables created in all databases.
|
||||
# @SIDE_EFFECT: Creates physical database files if they don't exist.
|
||||
def init_db():
|
||||
with belief_scope("init_db"):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(bind=tasks_engine)
|
||||
Base.metadata.create_all(bind=auth_engine)
|
||||
# [/DEF:init_db:Function]
|
||||
|
||||
# [DEF:get_db:Function]
|
||||
@@ -84,4 +111,18 @@ def get_tasks_db():
|
||||
db.close()
|
||||
# [/DEF:get_tasks_db:Function]
|
||||
|
||||
# [DEF:get_auth_db:Function]
|
||||
# @PURPOSE: Dependency for getting an authentication database session.
|
||||
# @PRE: AuthSessionLocal is initialized.
|
||||
# @POST: Session is closed after use.
|
||||
# @RETURN: Generator[Session, None, None]
|
||||
def get_auth_db():
|
||||
with belief_scope("get_auth_db"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:get_auth_db:Function]
|
||||
|
||||
# [/DEF:backend.src.core.database:Module]
|
||||
|
||||
@@ -68,6 +68,18 @@ class PluginBase(ABC):
|
||||
pass
|
||||
# [/DEF:version:Function]
|
||||
|
||||
@property
|
||||
# [DEF:required_permission:Function]
|
||||
# @PURPOSE: Returns the required permission string to execute this plugin.
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns string permission.
|
||||
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
|
||||
def required_permission(self) -> str:
|
||||
"""The permission string required to execute this plugin."""
|
||||
with belief_scope("required_permission"):
|
||||
return f"plugin:{self.id}:execute"
|
||||
# [/DEF:required_permission:Function]
|
||||
|
||||
@property
|
||||
# [DEF:ui_route:Function]
|
||||
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
# [DEF:Dependencies:Module]
|
||||
# @SEMANTICS: dependency, injection, singleton, factory
|
||||
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
|
||||
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
||||
# @LAYER: Core
|
||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from .core.plugin_loader import PluginLoader
|
||||
from .core.task_manager import TaskManager
|
||||
from .core.config_manager import ConfigManager
|
||||
from .core.scheduler import SchedulerService
|
||||
from .core.database import init_db
|
||||
from .core.database import init_db, get_auth_db
|
||||
from .core.logger import logger, belief_scope
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .models.auth import User
|
||||
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
@@ -77,4 +84,70 @@ def get_scheduler_service() -> SchedulerService:
|
||||
return scheduler_service
|
||||
# [/DEF:get_scheduler_service:Function]
|
||||
|
||||
# [DEF:oauth2_scheme:Variable]
|
||||
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
# [/DEF:oauth2_scheme:Variable]
|
||||
|
||||
# [DEF:get_current_user:Function]
|
||||
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
|
||||
# @PRE: JWT token provided in Authorization header.
|
||||
# @POST: Returns the User object if token is valid.
|
||||
# @THROW: HTTPException 401 if token is invalid or user not found.
|
||||
# @PARAM: token (str) - Extracted JWT token.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: User - The authenticated user.
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
|
||||
with belief_scope("get_current_user"):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
# [/DEF:get_current_user:Function]
|
||||
|
||||
# [DEF:has_permission:Function]
|
||||
# @PURPOSE: Dependency for checking if the current user has a specific permission.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns True if user has permission.
|
||||
# @THROW: HTTPException 403 if permission is denied.
|
||||
# @PARAM: resource (str) - The resource identifier.
|
||||
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
|
||||
# @RETURN: User - The authenticated user if permission granted.
|
||||
def has_permission(resource: str, action: str):
|
||||
def permission_checker(current_user: User = Depends(get_current_user)):
|
||||
with belief_scope("has_permission", f"{resource}:{action}"):
|
||||
# Union of all permissions across all roles
|
||||
for role in current_user.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.resource == resource and perm.action == action:
|
||||
return current_user
|
||||
|
||||
# Special case for Admin role (full access)
|
||||
if any(role.name == "Admin" for role in current_user.roles):
|
||||
return current_user
|
||||
|
||||
from .core.auth.logger import log_security_event
|
||||
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission denied for {resource}:{action}"
|
||||
)
|
||||
return permission_checker
|
||||
# [/DEF:has_permission:Function]
|
||||
|
||||
# [/DEF:Dependencies:Module]
|
||||
104
backend/src/models/auth.py
Normal file
104
backend/src/models/auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# [DEF:backend.src.models.auth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, models, user, role, permission, sqlalchemy
|
||||
# @PURPOSE: SQLAlchemy models for multi-user authentication and authorization.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
#
|
||||
# @INVARIANT: Usernames and emails must be unique.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from .mapping import Base
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:generate_uuid:Function]
|
||||
# @PURPOSE: Generates a unique UUID string.
|
||||
# @POST: Returns a string representation of a new UUID.
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
# [/DEF:generate_uuid:Function]
|
||||
|
||||
# [DEF:user_roles:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Users and Roles.
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:user_roles:Table]
|
||||
|
||||
# [DEF:role_permissions:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Roles and Permissions.
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", String, ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:role_permissions:Table]
|
||||
|
||||
# [DEF:User:Class]
|
||||
# @PURPOSE: Represents an identity that can authenticate to the system.
|
||||
# @RELATION: HAS_MANY -> Role (via user_roles)
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
email = Column(String, unique=True, index=True, nullable=True)
|
||||
password_hash = Column(String, nullable=True)
|
||||
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
# [/DEF:User:Class]
|
||||
|
||||
# [DEF:Role:Class]
|
||||
# @PURPOSE: Represents a collection of permissions.
|
||||
# @RELATION: HAS_MANY -> User (via user_roles)
|
||||
# @RELATION: HAS_MANY -> Permission (via role_permissions)
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
name = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
# [/DEF:Role:Class]
|
||||
|
||||
# [DEF:Permission:Class]
|
||||
# @PURPOSE: Represents a specific capability within the system.
|
||||
# @RELATION: HAS_MANY -> Role (via role_permissions)
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
resource = Column(String, nullable=False) # e.g. "plugin:backup"
|
||||
action = Column(String, nullable=False) # e.g. "READ", "EXECUTE", "WRITE"
|
||||
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
# [/DEF:Permission:Class]
|
||||
|
||||
# [DEF:ADGroupMapping:Class]
|
||||
# @PURPOSE: Maps an Active Directory group to a local System Role.
|
||||
# @RELATION: DEPENDS_ON -> Role
|
||||
class ADGroupMapping(Base):
|
||||
__tablename__ = "ad_group_mappings"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
ad_group_name = Column(String, unique=True, index=True, nullable=False)
|
||||
role_id = Column(String, ForeignKey("roles.id"), nullable=False)
|
||||
|
||||
role = relationship("Role")
|
||||
# [/DEF:ADGroupMapping:Class]
|
||||
|
||||
# [/DEF:backend.src.models.auth:Module]
|
||||
124
backend/src/schemas/auth.py
Normal file
124
backend/src/schemas/auth.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# [DEF:backend.src.schemas.auth:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, schemas, pydantic, user, token
|
||||
# @PURPOSE: Pydantic schemas for authentication requests and responses.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> pydantic
|
||||
#
|
||||
# @INVARIANT: Sensitive fields like password must not be included in response schemas.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:Token:Class]
|
||||
# @PURPOSE: Represents a JWT access token response.
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
# [/DEF:Token:Class]
|
||||
|
||||
# [DEF:TokenData:Class]
|
||||
# @PURPOSE: Represents the data encoded in a JWT token.
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
scopes: List[str] = []
|
||||
# [/DEF:TokenData:Class]
|
||||
|
||||
# [DEF:PermissionSchema:Class]
|
||||
# @PURPOSE: Represents a permission in API responses.
|
||||
class PermissionSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
resource: str
|
||||
action: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:PermissionSchema:Class]
|
||||
|
||||
# [DEF:RoleSchema:Class]
|
||||
# @PURPOSE: Represents a role in API responses.
|
||||
class RoleSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permissions: List[PermissionSchema] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:RoleSchema:Class]
|
||||
|
||||
# [DEF:RoleCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new role.
|
||||
class RoleCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permissions: List[str] = [] # List of permission IDs or "resource:action" strings
|
||||
# [/DEF:RoleCreate:Class]
|
||||
|
||||
# [DEF:RoleUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing role.
|
||||
class RoleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
permissions: Optional[List[str]] = None
|
||||
# [/DEF:RoleUpdate:Class]
|
||||
|
||||
# [DEF:ADGroupMappingSchema:Class]
|
||||
# @PURPOSE: Represents an AD Group to Role mapping in API responses.
|
||||
class ADGroupMappingSchema(BaseModel):
|
||||
id: str
|
||||
ad_group: str
|
||||
role_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:ADGroupMappingSchema:Class]
|
||||
|
||||
# [DEF:ADGroupMappingCreate:Class]
|
||||
# @PURPOSE: Schema for creating an AD Group mapping.
|
||||
class ADGroupMappingCreate(BaseModel):
|
||||
ad_group: str
|
||||
role_id: str
|
||||
# [/DEF:ADGroupMappingCreate:Class]
|
||||
|
||||
# [DEF:UserBase:Class]
|
||||
# @PURPOSE: Base schema for user data.
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
email: Optional[EmailStr] = None
|
||||
is_active: bool = True
|
||||
# [/DEF:UserBase:Class]
|
||||
|
||||
# [DEF:UserCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new user.
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
roles: List[str] = []
|
||||
# [/DEF:UserCreate:Class]
|
||||
|
||||
# [DEF:UserUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing user.
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
roles: Optional[List[str]] = None
|
||||
# [/DEF:UserUpdate:Class]
|
||||
|
||||
# [DEF:User:Class]
|
||||
# @PURPOSE: Schema for user data in API responses.
|
||||
class User(UserBase):
|
||||
id: str
|
||||
auth_source: str
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
roles: List[RoleSchema] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:User:Class]
|
||||
|
||||
# [/DEF:backend.src.schemas.auth:Module]
|
||||
82
backend/src/scripts/create_admin.py
Normal file
82
backend/src/scripts/create_admin.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# [DEF:backend.src.scripts.create_admin:Module]
|
||||
#
|
||||
# @SEMANTICS: admin, setup, user, auth, cli
|
||||
# @PURPOSE: CLI tool for creating the initial admin user.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: USES -> backend.src.core.auth.security
|
||||
# @RELATION: USES -> backend.src.core.database
|
||||
# @RELATION: USES -> backend.src.models.auth
|
||||
#
|
||||
# @INVARIANT: Admin user must have the "Admin" role.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import AuthSessionLocal, init_db
|
||||
from src.core.auth.security import get_password_hash
|
||||
from src.models.auth import User, Role, Permission
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:create_admin:Function]
|
||||
# @PURPOSE: Creates an admin user and necessary roles/permissions.
|
||||
# @PRE: username and password provided via CLI.
|
||||
# @POST: Admin user exists in auth.db.
|
||||
#
|
||||
# @PARAM: username (str) - Admin username.
|
||||
# @PARAM: password (str) - Admin password.
|
||||
def create_admin(username, password):
|
||||
with belief_scope("create_admin"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
# 1. Ensure Admin role exists
|
||||
admin_role = db.query(Role).filter(Role.name == "Admin").first()
|
||||
if not admin_role:
|
||||
logger.info("Creating Admin role...")
|
||||
admin_role = Role(name="Admin", description="System Administrator")
|
||||
db.add(admin_role)
|
||||
db.commit()
|
||||
db.refresh(admin_role)
|
||||
|
||||
# 2. Check if user already exists
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
logger.warning(f"User {username} already exists.")
|
||||
return
|
||||
|
||||
# 3. Create Admin user
|
||||
logger.info(f"Creating admin user: {username}")
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
auth_source="LOCAL",
|
||||
is_active=True
|
||||
)
|
||||
new_user.roles.append(admin_role)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
logger.info(f"Admin user {username} created successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create admin user: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:create_admin:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Create initial admin user")
|
||||
parser.add_argument("--username", required=True, help="Admin username")
|
||||
parser.add_argument("--password", required=True, help="Admin password")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Ensure DB is initialized before creating admin
|
||||
init_db()
|
||||
create_admin(args.username, args.password)
|
||||
|
||||
# [/DEF:backend.src.scripts.create_admin:Module]
|
||||
44
backend/src/scripts/init_auth_db.py
Normal file
44
backend/src/scripts/init_auth_db.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# [DEF:backend.src.scripts.init_auth_db:Module]
|
||||
#
|
||||
# @SEMANTICS: setup, database, auth, migration
|
||||
# @PURPOSE: Initializes the auth database and creates the necessary tables.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: CALLS -> backend.src.core.database.init_db
|
||||
#
|
||||
# @INVARIANT: Safe to run multiple times (idempotent).
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import init_db, auth_engine
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.scripts.seed_permissions import seed_permissions
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:run_init:Function]
|
||||
# @PURPOSE: Main entry point for the initialization script.
|
||||
# @POST: auth.db is initialized with the correct schema and seeded permissions.
|
||||
def run_init():
|
||||
with belief_scope("init_auth_db"):
|
||||
logger.info("Initializing authentication database...")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("Authentication database initialized successfully.")
|
||||
|
||||
# Seed permissions
|
||||
seed_permissions()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize authentication database: {e}")
|
||||
sys.exit(1)
|
||||
# [/DEF:run_init:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_init()
|
||||
|
||||
# [/DEF:backend.src.scripts.init_auth_db:Module]
|
||||
79
backend/src/scripts/seed_permissions.py
Normal file
79
backend/src/scripts/seed_permissions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# [DEF:backend.src.scripts.seed_permissions:Module]
|
||||
#
|
||||
# @SEMANTICS: setup, database, auth, permissions, seeding
|
||||
# @PURPOSE: Populates the auth database with initial system permissions.
|
||||
# @LAYER: Scripts
|
||||
# @RELATION: USES -> backend.src.core.database.get_auth_db
|
||||
# @RELATION: USES -> backend.src.models.auth.Permission
|
||||
#
|
||||
# @INVARIANT: Safe to run multiple times (idempotent).
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import AuthSessionLocal
|
||||
from src.models.auth import Permission
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:INITIAL_PERMISSIONS:Constant]
|
||||
INITIAL_PERMISSIONS = [
|
||||
# Admin Permissions
|
||||
{"resource": "admin:users", "action": "READ"},
|
||||
{"resource": "admin:users", "action": "WRITE"},
|
||||
{"resource": "admin:roles", "action": "READ"},
|
||||
{"resource": "admin:roles", "action": "WRITE"},
|
||||
{"resource": "admin:settings", "action": "READ"},
|
||||
{"resource": "admin:settings", "action": "WRITE"},
|
||||
|
||||
# Plugin Permissions
|
||||
{"resource": "plugin:backup", "action": "EXECUTE"},
|
||||
{"resource": "plugin:migration", "action": "EXECUTE"},
|
||||
{"resource": "plugin:mapper", "action": "EXECUTE"},
|
||||
{"resource": "plugin:search", "action": "EXECUTE"},
|
||||
{"resource": "plugin:git", "action": "EXECUTE"},
|
||||
{"resource": "plugin:storage", "action": "EXECUTE"},
|
||||
{"resource": "plugin:debug", "action": "EXECUTE"},
|
||||
]
|
||||
# [/DEF:INITIAL_PERMISSIONS:Constant]
|
||||
|
||||
# [DEF:seed_permissions:Function]
|
||||
# @PURPOSE: Inserts missing permissions into the database.
|
||||
# @POST: All INITIAL_PERMISSIONS exist in the DB.
|
||||
def seed_permissions():
|
||||
with belief_scope("seed_permissions"):
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
logger.info("Seeding permissions...")
|
||||
count = 0
|
||||
for perm_data in INITIAL_PERMISSIONS:
|
||||
exists = db.query(Permission).filter(
|
||||
Permission.resource == perm_data["resource"],
|
||||
Permission.action == perm_data["action"]
|
||||
).first()
|
||||
|
||||
if not exists:
|
||||
new_perm = Permission(
|
||||
resource=perm_data["resource"],
|
||||
action=perm_data["action"]
|
||||
)
|
||||
db.add(new_perm)
|
||||
count += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Seeding completed. Added {count} new permissions.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed permissions: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
# [/DEF:seed_permissions:Function]
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_permissions()
|
||||
|
||||
# [/DEF:backend.src.scripts.seed_permissions:Module]
|
||||
115
backend/src/services/auth_service.py
Normal file
115
backend/src/services/auth_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# [DEF:backend.src.services.auth_service:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, service, business-logic, login, jwt
|
||||
# @PURPOSE: Orchestrates authentication business logic.
|
||||
# @LAYER: Service
|
||||
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
|
||||
# @RELATION: USES -> backend.src.core.auth.security
|
||||
# @RELATION: USES -> backend.src.core.auth.jwt
|
||||
#
|
||||
# @INVARIANT: Authentication must verify both credentials and account status.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.auth import User, Role
|
||||
from ..core.auth.repository import AuthRepository
|
||||
from ..core.auth.security import verify_password, get_password_hash
|
||||
from ..core.auth.jwt import create_access_token
|
||||
from ..core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthService:Class]
|
||||
# @PURPOSE: Provides high-level authentication services.
|
||||
class AuthService:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the service with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.repo = AuthRepository(db)
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:authenticate_user:Function]
|
||||
# @PURPOSE: Authenticates a user with username and password.
|
||||
# @PRE: username and password are provided.
|
||||
# @POST: Returns User object if authentication succeeds, else None.
|
||||
# @SIDE_EFFECT: Updates last_login timestamp on success.
|
||||
# @PARAM: username (str) - The username.
|
||||
# @PARAM: password (str) - The plain password.
|
||||
# @RETURN: Optional[User] - The authenticated user or None.
|
||||
def authenticate_user(self, username: str, password: str):
|
||||
with belief_scope("AuthService.authenticate_user"):
|
||||
user = self.repo.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
return None
|
||||
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
self.repo.update_last_login(user)
|
||||
return user
|
||||
# [/DEF:authenticate_user:Function]
|
||||
|
||||
# [DEF:create_session:Function]
|
||||
# @PURPOSE: Creates a JWT session for an authenticated user.
|
||||
# @PRE: user is a valid User object.
|
||||
# @POST: Returns a dictionary with access_token and token_type.
|
||||
# @PARAM: user (User) - The authenticated user.
|
||||
# @RETURN: Dict[str, str] - Session data.
|
||||
def create_session(self, user) -> Dict[str, str]:
|
||||
with belief_scope("AuthService.create_session"):
|
||||
# Collect role names for scopes
|
||||
scopes = [role.name for role in user.roles]
|
||||
|
||||
token_data = {
|
||||
"sub": user.username,
|
||||
"scopes": scopes
|
||||
}
|
||||
|
||||
access_token = create_access_token(data=token_data)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
# [/DEF:create_session:Function]
|
||||
|
||||
# [DEF:provision_adfs_user:Function]
|
||||
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
|
||||
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
|
||||
# @POST: User is created/updated and assigned roles based on groups.
|
||||
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
|
||||
# @RETURN: User - The provisioned user.
|
||||
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
|
||||
with belief_scope("AuthService.provision_adfs_user"):
|
||||
username = user_info.get("upn") or user_info.get("email")
|
||||
email = user_info.get("email")
|
||||
ad_groups = user_info.get("groups", [])
|
||||
|
||||
user = self.repo.get_user_by_username(username)
|
||||
if not user:
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
auth_source="ADFS",
|
||||
is_active=True
|
||||
)
|
||||
self.repo.db.add(user)
|
||||
|
||||
# Update roles based on group mappings
|
||||
from ..models.auth import ADGroupMapping
|
||||
mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter(
|
||||
ADGroupMapping.ad_group_name.in_(ad_groups)
|
||||
).all()
|
||||
|
||||
user.roles = mapped_roles
|
||||
self.repo.db.commit()
|
||||
self.repo.db.refresh(user)
|
||||
return user
|
||||
# [/DEF:provision_adfs_user:Function]
|
||||
|
||||
# [/DEF:AuthService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.auth_service:Module]
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
@@ -9,6 +9,13 @@
|
||||
import { page } from '$app/stores';
|
||||
import { t } from '$lib/i18n';
|
||||
import { LanguageSwitcher } from '$lib/ui';
|
||||
import { auth } from '../lib/auth/store';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</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 />
|
||||
|
||||
{#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>
|
||||
</header>
|
||||
<!-- [/DEF:Navbar:Component] -->
|
||||
|
||||
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal file
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal 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] -->
|
||||
@@ -25,6 +25,22 @@ export const getWsUrl = (taskId) => {
|
||||
};
|
||||
// [/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]
|
||||
// @PURPOSE: Generic GET request wrapper.
|
||||
// @PRE: endpoint string is provided.
|
||||
@@ -34,7 +50,9 @@ export const getWsUrl = (taskId) => {
|
||||
async function fetchApi(endpoint) {
|
||||
try {
|
||||
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) {
|
||||
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}'}}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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}'}}`);
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
@@ -112,6 +126,9 @@ async function requestApi(endpoint, method = 'GET', body = null) {
|
||||
// [DEF:api:Data]
|
||||
// @PURPOSE: API client object with specific methods.
|
||||
export const api = {
|
||||
fetchApi,
|
||||
postApi,
|
||||
requestApi,
|
||||
getPlugins: () => fetchApi('/plugins'),
|
||||
getTasks: () => fetchApi('/tasks'),
|
||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
import Navbar from '../components/Navbar.svelte';
|
||||
import Footer from '../components/Footer.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>
|
||||
|
||||
<Toast />
|
||||
|
||||
<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">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="p-4 flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</ProtectedRoute>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
216
frontend/src/routes/admin/roles/+page.svelte
Normal file
216
frontend/src/routes/admin/roles/+page.svelte
Normal 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] -->
|
||||
213
frontend/src/routes/admin/settings/+page.svelte
Normal file
213
frontend/src/routes/admin/settings/+page.svelte
Normal 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] -->
|
||||
273
frontend/src/routes/admin/users/+page.svelte
Normal file
273
frontend/src/routes/admin/users/+page.svelte
Normal 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] -->
|
||||
165
frontend/src/routes/login/+page.svelte
Normal file
165
frontend/src/routes/login/+page.svelte
Normal 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] -->
|
||||
@@ -8,9 +8,29 @@
|
||||
/** @type {import('./$types').PageData} */
|
||||
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 = {
|
||||
id: '',
|
||||
|
||||
@@ -18,7 +18,13 @@ export async function load() {
|
||||
settings: {
|
||||
environments: [],
|
||||
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'
|
||||
|
||||
240
frontend/src/services/adminService.js
Normal file
240
frontend/src/services/adminService.js
Normal 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]
|
||||
@@ -78,10 +78,15 @@ frontend/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── 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/
|
||||
│ │ ├── 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/
|
||||
│ └── auth/ # New: Auth components (ProtectedRoute, Login form)
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
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-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-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-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-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-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-014**: System MUST provide a web-based interface to manage Roles (Create, Update, Delete) and assign permissions to them.
|
||||
|
||||
### Key Entities
|
||||
|
||||
|
||||
@@ -8,74 +8,81 @@
|
||||
|
||||
*Goal: Initialize the auth database, core dependencies, and backend infrastructure.*
|
||||
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt`
|
||||
- [x] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py`
|
||||
- [x] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py`
|
||||
- [x] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py`
|
||||
- [x] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py`
|
||||
- [x] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py`
|
||||
- [x] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.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)
|
||||
|
||||
*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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] 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] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py`
|
||||
- [x] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py`
|
||||
- [x] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py`
|
||||
- [x] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py`
|
||||
- [x] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py`
|
||||
- [x] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py`
|
||||
- [x] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py`
|
||||
- [x] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py`
|
||||
- [x] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts`
|
||||
- [x] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte`
|
||||
- [x] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte`
|
||||
- [x] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte`
|
||||
- [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)
|
||||
|
||||
*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`
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
|
||||
- [ ] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
|
||||
- [ ] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
|
||||
- [ ] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`
|
||||
- [x] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py`
|
||||
- [x] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py`
|
||||
- [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
|
||||
- [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
|
||||
- [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py`
|
||||
- [ ] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py`
|
||||
- [ ] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py`
|
||||
- [x] T051 [US2] Implement `adminService.js` for frontend API orchestration
|
||||
- [ ] 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)
|
||||
|
||||
*Goal: Enable corporate SSO login via ADFS and JIT provisioning.*
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py`
|
||||
- [ ] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py`
|
||||
- [x] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py`
|
||||
- [x] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script
|
||||
- [x] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py`
|
||||
- [x] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py`
|
||||
- [x] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte`
|
||||
- [x] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte`
|
||||
- [x] T052 [US3] Extend Admin API with AD mapping endpoints in `backend/src/api/routes/admin.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
|
||||
|
||||
*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`
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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`
|
||||
- [ ] 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/`
|
||||
- [ ] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
|
||||
- [x] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py`
|
||||
- [x] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py`
|
||||
- [x] T034 Verify error messages are generic (no username enumeration) across all auth endpoints
|
||||
- [x] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts`
|
||||
- [x] T036 Final manual test of switching between Local and ADFS login flows
|
||||
- [x] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte`
|
||||
- [x] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py`
|
||||
- [x] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components
|
||||
- [x] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/`
|
||||
- [x] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
Reference in New Issue
Block a user