Передаем на тест
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,3 +67,4 @@ backend/mappings.db
|
|||||||
|
|
||||||
backend/tasks.db
|
backend/tasks.db
|
||||||
backend/logs
|
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
|
SYNC IMPACT REPORT
|
||||||
Version: 1.8.0 (Frontend Unification)
|
Version: 1.9.0 (Security & RBAC Mandate)
|
||||||
Changes:
|
Changes:
|
||||||
- Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n).
|
- Added Principle IX: Security & Access Control (Mandating granular permissions for plugins).
|
||||||
Templates Status:
|
Templates Status:
|
||||||
- .specify/templates/plan-template.md: ✅ Aligned.
|
- .specify/templates/plan-template.md: ✅ Aligned.
|
||||||
- .specify/templates/spec-template.md: ✅ Aligned.
|
- .specify/templates/spec-template.md: ✅ Aligned.
|
||||||
@@ -41,6 +41,11 @@ To ensure a consistent and accessible user experience, all frontend implementati
|
|||||||
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
|
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
|
||||||
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
|
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
|
||||||
|
|
||||||
|
### IX. Security & Access Control
|
||||||
|
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
|
||||||
|
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
|
||||||
|
- **Registration**: These permissions MUST be registered in the system database during initialization or plugin loading to ensure they are available for role assignment in the Admin UI.
|
||||||
|
|
||||||
## File Structure Standards
|
## File Structure Standards
|
||||||
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
|
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
|
||||||
- Python Module Headers (`.py`)
|
- Python Module Headers (`.py`)
|
||||||
@@ -68,4 +73,4 @@ This Constitution establishes the "Semantic Code Generation Protocol" as the sup
|
|||||||
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
|
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
|
||||||
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
|
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
|
||||||
|
|
||||||
**Version**: 1.8.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-26
|
**Version**: 1.9.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-27
|
||||||
|
|||||||
BIN
backend/backend/auth.db
Normal file
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
|
more-itertools==10.8.0
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
|
pydantic-settings
|
||||||
pydantic_core==2.41.5
|
pydantic_core==2.41.5
|
||||||
python-multipart==0.0.21
|
python-multipart==0.0.21
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-jose[cryptography]
|
||||||
|
PyJWT
|
||||||
RapidFuzz==3.14.3
|
RapidFuzz==3.14.3
|
||||||
referencing==0.37.0
|
referencing==0.37.0
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
@@ -45,3 +49,5 @@ pandas
|
|||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
openpyxl
|
openpyxl
|
||||||
GitPython==3.1.44
|
GitPython==3.1.44
|
||||||
|
itsdangerous
|
||||||
|
email-validator
|
||||||
@@ -1,59 +1,108 @@
|
|||||||
# [DEF:AuthModule:Module]
|
# [DEF:backend.src.api.auth:Module]
|
||||||
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
|
#
|
||||||
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
|
# @SEMANTICS: api, auth, routes, login, logout
|
||||||
# @LAYER: UI (API)
|
# @PURPOSE: Authentication API endpoints.
|
||||||
# @RELATION: Used by API routers to protect endpoints that require authentication.
|
# @LAYER: API
|
||||||
|
# @RELATION: USES -> backend.src.services.auth_service.AuthService
|
||||||
|
# @RELATION: USES -> backend.src.core.database.get_auth_db
|
||||||
|
#
|
||||||
|
# @INVARIANT: All auth endpoints must return consistent error codes.
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
# [SECTION: IMPORTS]
|
||||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from starlette.config import Config
|
from sqlalchemy.orm import Session
|
||||||
|
from ..core.database import get_auth_db
|
||||||
|
from ..services.auth_service import AuthService
|
||||||
|
from ..schemas.auth import Token, User as UserSchema
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..core.auth.oauth import oauth
|
||||||
|
from ..core.auth.logger import log_security_event
|
||||||
|
from ..core.logger import belief_scope
|
||||||
|
import starlette.requests
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
|
# [DEF:router:Variable]
|
||||||
# Create an in-memory .env file
|
# @PURPOSE: APIRouter instance for authentication routes.
|
||||||
from io import StringIO
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
config_data = StringIO("""
|
# [/DEF:router:Variable]
|
||||||
ADFS_CLIENT_ID=your-client-id
|
|
||||||
ADFS_CLIENT_SECRET=your-client-secret
|
|
||||||
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
|
|
||||||
""")
|
|
||||||
config = Config(config_data)
|
|
||||||
oauth = OAuth(config)
|
|
||||||
|
|
||||||
oauth.register(
|
# [DEF:login_for_access_token:Function]
|
||||||
name='adfs',
|
# @PURPOSE: Authenticates a user and returns a JWT access token.
|
||||||
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
|
# @PRE: form_data contains username and password.
|
||||||
client_kwargs={'scope': 'openid profile email'}
|
# @POST: Returns a Token object on success.
|
||||||
)
|
# @THROW: HTTPException 401 if authentication fails.
|
||||||
|
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
|
||||||
oauth2_scheme = OAuth2AuthorizationCodeBearer(
|
# @PARAM: db (Session) - Auth database session.
|
||||||
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
|
# @RETURN: Token - The generated JWT token.
|
||||||
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
|
@router.post("/login", response_model=Token)
|
||||||
)
|
async def login_for_access_token(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
# [DEF:get_current_user:Function]
|
db: Session = Depends(get_auth_db)
|
||||||
# @PURPOSE: Dependency to get the current user from the ADFS token.
|
):
|
||||||
# @PARAM: token (str) - The OAuth2 bearer token.
|
with belief_scope("api.auth.login"):
|
||||||
# @PRE: token should be provided via Authorization header.
|
auth_service = AuthService(db)
|
||||||
# @POST: Returns user details if authenticated, else raises 401.
|
user = auth_service.authenticate_user(form_data.username, form_data.password)
|
||||||
# @RETURN: Dict[str, str] - User information.
|
if not user:
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
|
||||||
"""
|
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Not authenticated",
|
detail="Incorrect username or password",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
# A real implementation would return a user object.
|
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
|
||||||
return {"placeholder_user": "user@example.com"}
|
return auth_service.create_session(user)
|
||||||
# [/DEF:get_current_user:Function]
|
# [/DEF:login_for_access_token:Function]
|
||||||
# [/DEF:AuthModule:Module]
|
|
||||||
|
# [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: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 typing import List
|
||||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||||
from ...models.storage import StorageConfig
|
from ...models.storage import StorageConfig
|
||||||
from ...dependencies import get_config_manager
|
from ...dependencies import get_config_manager, has_permission
|
||||||
from ...core.config_manager import ConfigManager
|
from ...core.config_manager import ConfigManager
|
||||||
from ...core.logger import logger, belief_scope
|
from ...core.logger import logger, belief_scope
|
||||||
from ...core.superset_client import SupersetClient
|
from ...core.superset_client import SupersetClient
|
||||||
@@ -29,7 +29,10 @@ router = APIRouter()
|
|||||||
# @POST: Returns masked AppConfig.
|
# @POST: Returns masked AppConfig.
|
||||||
# @RETURN: AppConfig - The current configuration.
|
# @RETURN: AppConfig - The current configuration.
|
||||||
@router.get("", response_model=AppConfig)
|
@router.get("", response_model=AppConfig)
|
||||||
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
async def get_settings(
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "READ"))
|
||||||
|
):
|
||||||
with belief_scope("get_settings"):
|
with belief_scope("get_settings"):
|
||||||
logger.info("[get_settings][Entry] Fetching all settings")
|
logger.info("[get_settings][Entry] Fetching all settings")
|
||||||
config = config_manager.get_config().copy(deep=True)
|
config = config_manager.get_config().copy(deep=True)
|
||||||
@@ -49,7 +52,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
|
|||||||
@router.patch("/global", response_model=GlobalSettings)
|
@router.patch("/global", response_model=GlobalSettings)
|
||||||
async def update_global_settings(
|
async def update_global_settings(
|
||||||
settings: GlobalSettings,
|
settings: GlobalSettings,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "WRITE"))
|
||||||
):
|
):
|
||||||
with belief_scope("update_global_settings"):
|
with belief_scope("update_global_settings"):
|
||||||
logger.info("[update_global_settings][Entry] Updating global settings")
|
logger.info("[update_global_settings][Entry] Updating global settings")
|
||||||
@@ -62,7 +66,10 @@ async def update_global_settings(
|
|||||||
# @PURPOSE: Retrieves storage-specific settings.
|
# @PURPOSE: Retrieves storage-specific settings.
|
||||||
# @RETURN: StorageConfig - The storage configuration.
|
# @RETURN: StorageConfig - The storage configuration.
|
||||||
@router.get("/storage", response_model=StorageConfig)
|
@router.get("/storage", response_model=StorageConfig)
|
||||||
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
async def get_storage_settings(
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "READ"))
|
||||||
|
):
|
||||||
with belief_scope("get_storage_settings"):
|
with belief_scope("get_storage_settings"):
|
||||||
return config_manager.get_config().settings.storage
|
return config_manager.get_config().settings.storage
|
||||||
# [/DEF:get_storage_settings:Function]
|
# [/DEF:get_storage_settings:Function]
|
||||||
@@ -73,7 +80,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
|
|||||||
# @POST: Storage settings are updated and saved.
|
# @POST: Storage settings are updated and saved.
|
||||||
# @RETURN: StorageConfig - The updated storage settings.
|
# @RETURN: StorageConfig - The updated storage settings.
|
||||||
@router.put("/storage", response_model=StorageConfig)
|
@router.put("/storage", response_model=StorageConfig)
|
||||||
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
|
async def update_storage_settings(
|
||||||
|
storage: StorageConfig,
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "WRITE"))
|
||||||
|
):
|
||||||
with belief_scope("update_storage_settings"):
|
with belief_scope("update_storage_settings"):
|
||||||
is_valid, message = config_manager.validate_path(storage.root_path)
|
is_valid, message = config_manager.validate_path(storage.root_path)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
|
|||||||
# @POST: Returns list of environments.
|
# @POST: Returns list of environments.
|
||||||
# @RETURN: List[Environment] - List of environments.
|
# @RETURN: List[Environment] - List of environments.
|
||||||
@router.get("/environments", response_model=List[Environment])
|
@router.get("/environments", response_model=List[Environment])
|
||||||
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
|
async def get_environments(
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "READ"))
|
||||||
|
):
|
||||||
with belief_scope("get_environments"):
|
with belief_scope("get_environments"):
|
||||||
logger.info("[get_environments][Entry] Fetching environments")
|
logger.info("[get_environments][Entry] Fetching environments")
|
||||||
return config_manager.get_environments()
|
return config_manager.get_environments()
|
||||||
@@ -106,7 +120,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
|
|||||||
@router.post("/environments", response_model=Environment)
|
@router.post("/environments", response_model=Environment)
|
||||||
async def add_environment(
|
async def add_environment(
|
||||||
env: Environment,
|
env: Environment,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("settings", "WRITE"))
|
||||||
):
|
):
|
||||||
with belief_scope("add_environment"):
|
with belief_scope("add_environment"):
|
||||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|||||||
from ...core.logger import belief_scope
|
from ...core.logger import belief_scope
|
||||||
|
|
||||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||||
from ...dependencies import get_task_manager
|
from ...dependencies import get_task_manager, has_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
|
|||||||
# @RETURN: Task - The created task instance.
|
# @RETURN: Task - The created task instance.
|
||||||
async def create_task(
|
async def create_task(
|
||||||
request: CreateTaskRequest,
|
request: CreateTaskRequest,
|
||||||
task_manager: TaskManager = Depends(get_task_manager)
|
task_manager: TaskManager = Depends(get_task_manager),
|
||||||
|
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create and start a new task for a given plugin.
|
Create and start a new task for a given plugin.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
project_root = Path(__file__).resolve().parent.parent.parent
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -18,7 +19,8 @@ import os
|
|||||||
|
|
||||||
from .dependencies import get_task_manager, get_scheduler_service
|
from .dependencies import get_task_manager, get_scheduler_service
|
||||||
from .core.logger import logger, belief_scope
|
from .core.logger import logger, belief_scope
|
||||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
|
||||||
|
from .api import auth
|
||||||
from .core.database import init_db
|
from .core.database import init_db
|
||||||
|
|
||||||
# [DEF:App:Global]
|
# [DEF:App:Global]
|
||||||
@@ -55,6 +57,10 @@ async def shutdown_event():
|
|||||||
scheduler.stop()
|
scheduler.stop()
|
||||||
# [/DEF:shutdown_event:Function]
|
# [/DEF:shutdown_event:Function]
|
||||||
|
|
||||||
|
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
||||||
|
from .core.auth.config import auth_config
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
|
|||||||
# [/DEF:log_requests:Function]
|
# [/DEF:log_requests:Function]
|
||||||
|
|
||||||
# Include API routes
|
# Include API routes
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
|
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
|
||||||
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
||||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||||
|
|||||||
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
|
# @LAYER: Core
|
||||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||||
# @RELATION: USES -> backend.src.models.mapping
|
# @RELATION: USES -> backend.src.models.mapping
|
||||||
|
# @RELATION: USES -> backend.src.core.auth.config
|
||||||
#
|
#
|
||||||
# @INVARIANT: A single engine instance is used for the entire application.
|
# @INVARIANT: A single engine instance is used for the entire application.
|
||||||
|
|
||||||
@@ -16,44 +17,70 @@ from ..models.mapping import Base
|
|||||||
from ..models.task import TaskRecord
|
from ..models.task import TaskRecord
|
||||||
from ..models.connection import ConnectionConfig
|
from ..models.connection import ConnectionConfig
|
||||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||||
|
from ..models.auth import User, Role, Permission, ADGroupMapping
|
||||||
from .logger import belief_scope
|
from .logger import belief_scope
|
||||||
|
from .auth.config import auth_config
|
||||||
import os
|
import os
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:DATABASE_URL:Constant]
|
# [DEF:DATABASE_URL:Constant]
|
||||||
|
# @PURPOSE: URL for the main mappings database.
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
||||||
# [/DEF:DATABASE_URL:Constant]
|
# [/DEF:DATABASE_URL:Constant]
|
||||||
|
|
||||||
# [DEF:TASKS_DATABASE_URL:Constant]
|
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||||
|
# @PURPOSE: URL for the tasks execution database.
|
||||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
||||||
# [/DEF:TASKS_DATABASE_URL:Constant]
|
# [/DEF:TASKS_DATABASE_URL:Constant]
|
||||||
|
|
||||||
|
# [DEF:AUTH_DATABASE_URL:Constant]
|
||||||
|
# @PURPOSE: URL for the authentication database.
|
||||||
|
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
|
||||||
|
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||||
|
|
||||||
# [DEF:engine:Variable]
|
# [DEF:engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for mappings database.
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
# [/DEF:engine:Variable]
|
# [/DEF:engine:Variable]
|
||||||
|
|
||||||
# [DEF:tasks_engine:Variable]
|
# [DEF:tasks_engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for tasks database.
|
||||||
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
# [/DEF:tasks_engine:Variable]
|
# [/DEF:tasks_engine:Variable]
|
||||||
|
|
||||||
|
# [DEF:auth_engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for authentication database.
|
||||||
|
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
# [/DEF:auth_engine:Variable]
|
||||||
|
|
||||||
# [DEF:SessionLocal:Class]
|
# [DEF:SessionLocal:Class]
|
||||||
# @PURPOSE: A session factory for the main mappings database.
|
# @PURPOSE: A session factory for the main mappings database.
|
||||||
|
# @PRE: engine is initialized.
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
# [/DEF:SessionLocal:Class]
|
# [/DEF:SessionLocal:Class]
|
||||||
|
|
||||||
# [DEF:TasksSessionLocal:Class]
|
# [DEF:TasksSessionLocal:Class]
|
||||||
# @PURPOSE: A session factory for the tasks execution database.
|
# @PURPOSE: A session factory for the tasks execution database.
|
||||||
|
# @PRE: tasks_engine is initialized.
|
||||||
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||||
# [/DEF:TasksSessionLocal:Class]
|
# [/DEF:TasksSessionLocal:Class]
|
||||||
|
|
||||||
|
# [DEF:AuthSessionLocal:Class]
|
||||||
|
# @PURPOSE: A session factory for the authentication database.
|
||||||
|
# @PRE: auth_engine is initialized.
|
||||||
|
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||||
|
# [/DEF:AuthSessionLocal:Class]
|
||||||
|
|
||||||
# [DEF:init_db:Function]
|
# [DEF:init_db:Function]
|
||||||
# @PURPOSE: Initializes the database by creating all tables.
|
# @PURPOSE: Initializes the database by creating all tables.
|
||||||
# @PRE: engine and tasks_engine are initialized.
|
# @PRE: engine, tasks_engine and auth_engine are initialized.
|
||||||
# @POST: Database tables created.
|
# @POST: Database tables created in all databases.
|
||||||
|
# @SIDE_EFFECT: Creates physical database files if they don't exist.
|
||||||
def init_db():
|
def init_db():
|
||||||
with belief_scope("init_db"):
|
with belief_scope("init_db"):
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
Base.metadata.create_all(bind=tasks_engine)
|
Base.metadata.create_all(bind=tasks_engine)
|
||||||
|
Base.metadata.create_all(bind=auth_engine)
|
||||||
# [/DEF:init_db:Function]
|
# [/DEF:init_db:Function]
|
||||||
|
|
||||||
# [DEF:get_db:Function]
|
# [DEF:get_db:Function]
|
||||||
@@ -84,4 +111,18 @@ def get_tasks_db():
|
|||||||
db.close()
|
db.close()
|
||||||
# [/DEF:get_tasks_db:Function]
|
# [/DEF:get_tasks_db:Function]
|
||||||
|
|
||||||
|
# [DEF:get_auth_db:Function]
|
||||||
|
# @PURPOSE: Dependency for getting an authentication database session.
|
||||||
|
# @PRE: AuthSessionLocal is initialized.
|
||||||
|
# @POST: Session is closed after use.
|
||||||
|
# @RETURN: Generator[Session, None, None]
|
||||||
|
def get_auth_db():
|
||||||
|
with belief_scope("get_auth_db"):
|
||||||
|
db = AuthSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# [/DEF:get_auth_db:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.core.database:Module]
|
# [/DEF:backend.src.core.database:Module]
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ class PluginBase(ABC):
|
|||||||
pass
|
pass
|
||||||
# [/DEF:version:Function]
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:required_permission:Function]
|
||||||
|
# @PURPOSE: Returns the required permission string to execute this plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string permission.
|
||||||
|
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
|
||||||
|
def required_permission(self) -> str:
|
||||||
|
"""The permission string required to execute this plugin."""
|
||||||
|
with belief_scope("required_permission"):
|
||||||
|
return f"plugin:{self.id}:execute"
|
||||||
|
# [/DEF:required_permission:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
# [DEF:ui_route:Function]
|
# [DEF:ui_route:Function]
|
||||||
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
|
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# [DEF:Dependencies:Module]
|
# [DEF:Dependencies:Module]
|
||||||
# @SEMANTICS: dependency, injection, singleton, factory
|
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
|
||||||
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError
|
||||||
from .core.plugin_loader import PluginLoader
|
from .core.plugin_loader import PluginLoader
|
||||||
from .core.task_manager import TaskManager
|
from .core.task_manager import TaskManager
|
||||||
from .core.config_manager import ConfigManager
|
from .core.config_manager import ConfigManager
|
||||||
from .core.scheduler import SchedulerService
|
from .core.scheduler import SchedulerService
|
||||||
from .core.database import init_db
|
from .core.database import init_db, get_auth_db
|
||||||
from .core.logger import logger, belief_scope
|
from .core.logger import logger, belief_scope
|
||||||
|
from .core.auth.jwt import decode_token
|
||||||
|
from .core.auth.repository import AuthRepository
|
||||||
|
from .models.auth import User
|
||||||
|
|
||||||
# Initialize singletons
|
# Initialize singletons
|
||||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||||
@@ -77,4 +84,70 @@ def get_scheduler_service() -> SchedulerService:
|
|||||||
return scheduler_service
|
return scheduler_service
|
||||||
# [/DEF:get_scheduler_service:Function]
|
# [/DEF:get_scheduler_service:Function]
|
||||||
|
|
||||||
|
# [DEF:oauth2_scheme:Variable]
|
||||||
|
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||||
|
# [/DEF:oauth2_scheme:Variable]
|
||||||
|
|
||||||
|
# [DEF:get_current_user:Function]
|
||||||
|
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
|
||||||
|
# @PRE: JWT token provided in Authorization header.
|
||||||
|
# @POST: Returns the User object if token is valid.
|
||||||
|
# @THROW: HTTPException 401 if token is invalid or user not found.
|
||||||
|
# @PARAM: token (str) - Extracted JWT token.
|
||||||
|
# @PARAM: db (Session) - Auth database session.
|
||||||
|
# @RETURN: User - The authenticated user.
|
||||||
|
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
|
||||||
|
with belief_scope("get_current_user"):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
repo = AuthRepository(db)
|
||||||
|
user = repo.get_user_by_username(username)
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
# [/DEF:get_current_user:Function]
|
||||||
|
|
||||||
|
# [DEF:has_permission:Function]
|
||||||
|
# @PURPOSE: Dependency for checking if the current user has a specific permission.
|
||||||
|
# @PRE: User is authenticated.
|
||||||
|
# @POST: Returns True if user has permission.
|
||||||
|
# @THROW: HTTPException 403 if permission is denied.
|
||||||
|
# @PARAM: resource (str) - The resource identifier.
|
||||||
|
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
|
||||||
|
# @RETURN: User - The authenticated user if permission granted.
|
||||||
|
def has_permission(resource: str, action: str):
|
||||||
|
def permission_checker(current_user: User = Depends(get_current_user)):
|
||||||
|
with belief_scope("has_permission", f"{resource}:{action}"):
|
||||||
|
# Union of all permissions across all roles
|
||||||
|
for role in current_user.roles:
|
||||||
|
for perm in role.permissions:
|
||||||
|
if perm.resource == resource and perm.action == action:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# Special case for Admin role (full access)
|
||||||
|
if any(role.name == "Admin" for role in current_user.roles):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
from .core.auth.logger import log_security_event
|
||||||
|
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Permission denied for {resource}:{action}"
|
||||||
|
)
|
||||||
|
return permission_checker
|
||||||
|
# [/DEF:has_permission:Function]
|
||||||
|
|
||||||
# [/DEF:Dependencies:Module]
|
# [/DEF:Dependencies:Module]
|
||||||
104
backend/src/models/auth.py
Normal file
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 { page } from '$app/stores';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { LanguageSwitcher } from '$lib/ui';
|
import { LanguageSwitcher } from '$lib/ui';
|
||||||
|
import { auth } from '../lib/auth/store';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout();
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="bg-white shadow-md p-4 flex justify-between items-center">
|
<header class="bg-white shadow-md p-4 flex justify-between items-center">
|
||||||
@@ -41,7 +48,32 @@
|
|||||||
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
|
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if $auth.isAuthenticated && $auth.user?.roles?.some(r => r.name === 'Admin')}
|
||||||
|
<div class="relative inline-block group">
|
||||||
|
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
|
||||||
|
{$t.nav.admin}
|
||||||
|
</button>
|
||||||
|
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
|
||||||
|
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_users}</a>
|
||||||
|
<a href="/admin/roles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_roles}</a>
|
||||||
|
<a href="/admin/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_settings}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
{#if $auth.isAuthenticated}
|
||||||
|
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
|
||||||
|
<span class="text-sm text-gray-600">{$auth.user?.username}</span>
|
||||||
|
<button
|
||||||
|
on:click={handleLogout}
|
||||||
|
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<!-- [/DEF:Navbar:Component] -->
|
<!-- [/DEF:Navbar:Component] -->
|
||||||
|
|||||||
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:getWsUrl:Function]
|
||||||
|
|
||||||
|
// [DEF:getAuthHeaders:Function]
|
||||||
|
// @PURPOSE: Returns headers with Authorization if token exists.
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
// [/DEF:getAuthHeaders:Function]
|
||||||
|
|
||||||
// [DEF:fetchApi:Function]
|
// [DEF:fetchApi:Function]
|
||||||
// @PURPOSE: Generic GET request wrapper.
|
// @PURPOSE: Generic GET request wrapper.
|
||||||
// @PRE: endpoint string is provided.
|
// @PRE: endpoint string is provided.
|
||||||
@@ -34,7 +50,9 @@ export const getWsUrl = (taskId) => {
|
|||||||
async function fetchApi(endpoint) {
|
async function fetchApi(endpoint) {
|
||||||
try {
|
try {
|
||||||
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
|
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`);
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed with status ${response.status}`);
|
throw new Error(`API request failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -59,9 +77,7 @@ async function postApi(endpoint, body) {
|
|||||||
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
|
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -85,9 +101,7 @@ async function requestApi(endpoint, method = 'GET', body = null) {
|
|||||||
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
|
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
|
||||||
const options = {
|
const options = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
if (body) {
|
if (body) {
|
||||||
options.body = JSON.stringify(body);
|
options.body = JSON.stringify(body);
|
||||||
@@ -112,6 +126,9 @@ async function requestApi(endpoint, method = 'GET', body = null) {
|
|||||||
// [DEF:api:Data]
|
// [DEF:api:Data]
|
||||||
// @PURPOSE: API client object with specific methods.
|
// @PURPOSE: API client object with specific methods.
|
||||||
export const api = {
|
export const api = {
|
||||||
|
fetchApi,
|
||||||
|
postApi,
|
||||||
|
requestApi,
|
||||||
getPlugins: () => fetchApi('/plugins'),
|
getPlugins: () => fetchApi('/plugins'),
|
||||||
getTasks: () => fetchApi('/tasks'),
|
getTasks: () => fetchApi('/tasks'),
|
||||||
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
|
||||||
|
|||||||
@@ -3,11 +3,21 @@
|
|||||||
import Navbar from '../components/Navbar.svelte';
|
import Navbar from '../components/Navbar.svelte';
|
||||||
import Footer from '../components/Footer.svelte';
|
import Footer from '../components/Footer.svelte';
|
||||||
import Toast from '../components/Toast.svelte';
|
import Toast from '../components/Toast.svelte';
|
||||||
|
import ProtectedRoute from '../components/auth/ProtectedRoute.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
$: isLoginPage = $page.url.pathname === '/login';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<main class="bg-gray-50 min-h-screen flex flex-col">
|
<main class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
|
{#if isLoginPage}
|
||||||
|
<div class="p-4 flex-grow">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ProtectedRoute>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div class="p-4 flex-grow">
|
<div class="p-4 flex-grow">
|
||||||
@@ -15,4 +25,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</ProtectedRoute>
|
||||||
|
{/if}
|
||||||
</main>
|
</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} */
|
/** @type {import('./$types').PageData} */
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
let settings = data.settings;
|
let settings = data.settings || {
|
||||||
|
environments: [],
|
||||||
|
settings: {
|
||||||
|
storage: {
|
||||||
|
root_path: '',
|
||||||
|
backup_structure_pattern: '',
|
||||||
|
repo_structure_pattern: '',
|
||||||
|
filename_pattern: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$: settings = data.settings;
|
$: if (data.settings) {
|
||||||
|
settings = { ...data.settings };
|
||||||
|
if (settings.settings && !settings.settings.storage) {
|
||||||
|
settings.settings.storage = {
|
||||||
|
root_path: '',
|
||||||
|
backup_structure_pattern: '',
|
||||||
|
repo_structure_pattern: '',
|
||||||
|
filename_pattern: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let newEnv = {
|
let newEnv = {
|
||||||
id: '',
|
id: '',
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ export async function load() {
|
|||||||
settings: {
|
settings: {
|
||||||
environments: [],
|
environments: [],
|
||||||
settings: {
|
settings: {
|
||||||
default_environment_id: null
|
default_environment_id: null,
|
||||||
|
storage: {
|
||||||
|
root_path: '',
|
||||||
|
backup_structure_pattern: '',
|
||||||
|
repo_structure_pattern: '',
|
||||||
|
filename_pattern: ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: 'Failed to load settings'
|
error: 'Failed to load settings'
|
||||||
|
|||||||
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/
|
├── src/
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth/ # New: Frontend auth stores/logic
|
│ │ ├── auth/ # New: Frontend auth stores/logic
|
||||||
│ │ └── api/ # Update: Add auth headers to requests
|
│ │ └── api.js # Update: Add auth headers and export core methods
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── adminService.js # New: Service for admin API operations
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── login/ # New: Login page
|
│ │ ├── login/ # New: Login page
|
||||||
│ │ └── admin/ # New: Admin dashboard (Users/Roles)
|
│ │ └── admin/
|
||||||
|
│ │ ├── users/ # New: User Management UI
|
||||||
|
│ │ ├── roles/ # New: Role Management UI
|
||||||
|
│ │ └── settings/ # New: ADFS Configuration UI
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ └── auth/ # New: Auth components (ProtectedRoute, Login form)
|
│ └── auth/ # New: Auth components (ProtectedRoute, Login form)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,6 +49,30 @@ As an administrator, I want to assign specific plugin access rights to users so
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Role Management (Priority: P1)
|
||||||
|
|
||||||
|
As an administrator, I want to create and manage roles with specific permissions so that I can easily assign standard access sets to users.
|
||||||
|
|
||||||
|
**Why this priority**: Essential for scalable user management. Assigning individual permissions to every user is tedious and error-prone.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an administrator, **When** they navigate to the Role Management page, **Then** they see a list of all system roles.
|
||||||
|
2. **Given** an administrator, **When** they create a new role "Auditor" with "READ" permission on "Logs", **Then** the role is saved and available for assignment.
|
||||||
|
3. **Given** an administrator, **When** they update a role's permissions, **Then** all users with that role effectively gain/lose those permissions.
|
||||||
|
|
||||||
|
**Why this priority**: Security is a core requirement. Without granular permissions, all authenticated users would have full administrative access, which defeats the purpose of multi-user support.
|
||||||
|
|
||||||
|
**Independent Test**: Create two users with different permissions (e.g., User A has access to "Backup", User B does not). Verify User A can access the Backup tool while User B receives a 403 Forbidden error.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user with "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** the page loads successfully.
|
||||||
|
2. **Given** a user WITHOUT "Backup" plugin permission, **When** they navigate to the Backup tool, **Then** they are denied access (UI hides the link, API returns 403).
|
||||||
|
3. **Given** an administrator, **When** they edit a user's permissions, **Then** the changes take effect immediately or upon next login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### User Story 3 - ADFS Integration (Priority: P2)
|
### User Story 3 - ADFS Integration (Priority: P2)
|
||||||
|
|
||||||
As a corporate user, I want to log in using my organization's ADFS credentials so that I don't have to manage a separate password.
|
As a corporate user, I want to log in using my organization's ADFS credentials so that I don't have to manage a separate password.
|
||||||
@@ -78,17 +102,18 @@ As a corporate user, I want to log in using my organization's ADFS credentials s
|
|||||||
|
|
||||||
- **FR-001**: System MUST support local user authentication via username and password.
|
- **FR-001**: System MUST support local user authentication via username and password.
|
||||||
- **FR-002**: System MUST support authentication via ADFS (Active Directory Federation Services) using standard federation protocols.
|
- **FR-002**: System MUST support authentication via ADFS (Active Directory Federation Services) using standard federation protocols.
|
||||||
- **FR-003**: System MUST provide a mechanism to manage users (Create, Read, Update, Delete) - restricted to administrators.
|
- **FR-003**: System MUST provide a web-based interface to manage users (Create, Read, Update, Delete) - restricted to administrators.
|
||||||
- **FR-004**: System MUST implement Role-Based Access Control (RBAC) where permissions are assigned to Roles, and Roles are assigned to Users.
|
- **FR-004**: System MUST implement Role-Based Access Control (RBAC) where permissions are assigned to Roles, and Roles are assigned to Users.
|
||||||
- **FR-005**: System MUST enforce permissions at the server level for all plugin execution requests.
|
- **FR-005**: System MUST enforce permissions at the server level for all plugin execution requests.
|
||||||
- **FR-006**: System MUST enforce permissions at the user interface level (hide navigation items/buttons for unauthorized plugins).
|
- **FR-006**: System MUST enforce permissions at the user interface level (hide navigation items/buttons for unauthorized plugins).
|
||||||
- **FR-007**: System MUST securely store local user credentials.
|
- **FR-007**: System MUST securely store local user credentials.
|
||||||
- **FR-008**: System MUST support Just-In-Time (JIT) provisioning for ADFS users ONLY if they belong to a mapped AD group.
|
- **FR-008**: System MUST support Just-In-Time (JIT) provisioning for ADFS users ONLY if they belong to a mapped AD group.
|
||||||
- **FR-009**: System MUST provide a CLI utility to create an initial administrator account to prevent lockout during first deployment.
|
- **FR-009**: System MUST provide a CLI utility to create an initial administrator account to prevent lockout during first deployment.
|
||||||
- **FR-010**: System MUST allow configuring mappings between Active Directory Groups and local System Roles.
|
- **FR-010**: System MUST provide a web-based interface for configuring mappings between Active Directory Groups and local System Roles.
|
||||||
- **FR-011**: System MUST use JWT (JSON Web Tokens) for API session management.
|
- **FR-011**: System MUST use JWT (JSON Web Tokens) for API session management.
|
||||||
- **FR-012**: System MUST persist authentication and authorization data in a dedicated SQLite database (`auth.db`).
|
- **FR-012**: System MUST persist authentication and authorization data in a dedicated SQLite database (`auth.db`).
|
||||||
- **FR-013**: System MUST provide a unified login interface supporting both Local (Username/Password) and ADFS (SSO Button) authentication methods simultaneously.
|
- **FR-013**: System MUST provide a unified login interface supporting both Local (Username/Password) and ADFS (SSO Button) authentication methods simultaneously.
|
||||||
|
- **FR-014**: System MUST provide a web-based interface to manage Roles (Create, Update, Delete) and assign permissions to them.
|
||||||
|
|
||||||
### Key Entities
|
### Key Entities
|
||||||
|
|
||||||
|
|||||||
@@ -8,74 +8,81 @@
|
|||||||
|
|
||||||
*Goal: Initialize the auth database, core dependencies, and backend infrastructure.*
|
*Goal: Initialize the auth database, core dependencies, and backend infrastructure.*
|
||||||
|
|
||||||
- [ ] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt`
|
- [x] T001 Install backend dependencies (Authlib, Passlib, PyJWT, SQLAlchemy) in `backend/requirements.txt`
|
||||||
- [ ] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py`
|
- [x] T002 Implement core configuration for Auth and Database in `backend/src/core/auth/config.py`
|
||||||
- [ ] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py`
|
- [x] T003 Implement database connection logic for `auth.db` in `backend/src/core/database.py`
|
||||||
- [ ] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py`
|
- [x] T004 Create SQLAlchemy models for User, Role, Permission in `backend/src/models/auth.py`
|
||||||
- [ ] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py`
|
- [x] T005 Create migration/init script to generate `auth.db` schema in `backend/src/scripts/init_auth_db.py`
|
||||||
- [ ] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py`
|
- [x] T006 Implement password hashing utility using Passlib in `backend/src/core/auth/security.py`
|
||||||
- [ ] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.py`
|
- [x] T007 Implement JWT token generation and validation logic in `backend/src/core/auth/jwt.py`
|
||||||
- [ ] T008 [P] Implement CLI tool for creating the initial admin user in `backend/src/scripts/create_admin.py`
|
- [x] T008 [P] Implement CLI tool for creating the initial admin user in `backend/src/scripts/create_admin.py`
|
||||||
|
|
||||||
## Phase 2: User Story 1 - Local User Authentication (Priority: P1)
|
## Phase 2: User Story 1 - Local User Authentication (Priority: P1)
|
||||||
|
|
||||||
*Goal: Enable users to log in with username/password and receive a JWT session.*
|
*Goal: Enable users to log in with username/password and receive a JWT session.*
|
||||||
|
|
||||||
- [ ] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py`
|
- [x] T009 [US1] Create Pydantic schemas for User, UserCreate, Token in `backend/src/schemas/auth.py`
|
||||||
- [ ] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py`
|
- [x] T010 [US1] Implement `AuthRepository` for DB operations in `backend/src/core/auth/repository.py`
|
||||||
- [ ] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py`
|
- [x] T011 [US1] Implement `AuthService` for login logic (verify password, create token) in `backend/src/services/auth_service.py`
|
||||||
- [ ] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py`
|
- [x] T012 [US1] Create API endpoint `POST /api/auth/login` in `backend/src/api/auth.py`
|
||||||
- [ ] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py`
|
- [x] T013 [US1] Implement `get_current_user` dependency for JWT verification in `backend/src/dependencies.py`
|
||||||
- [ ] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py`
|
- [x] T014 [US1] Create API endpoint `GET /api/auth/me` to retrieve current user profile in `backend/src/api/auth.py`
|
||||||
- [ ] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py`
|
- [x] T043 [US1] Implement session revocation (Logout) endpoint in `backend/src/api/auth.py`
|
||||||
- [ ] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py`
|
- [x] T044 [US1] Implement account status check (`is_active`) in authentication flow in `backend/src/services/auth_service.py`
|
||||||
- [ ] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts`
|
- [x] T015 [US1] Implement frontend auth store (Svelte store) in `frontend/src/lib/auth/store.ts`
|
||||||
- [ ] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte`
|
- [x] T016 [US1] Implement Login Page UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/login/+page.svelte`
|
||||||
- [ ] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte`
|
- [x] T017 [US1] Integrate Login Page with Backend API in `frontend/src/routes/login/+page.svelte`
|
||||||
- [ ] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte`
|
- [x] T018 [US1] Implement `ProtectedRoute` component to redirect unauthenticated users in `frontend/src/components/auth/ProtectedRoute.svelte`
|
||||||
- [ ] T037 [US1] Implement password complexity validation logic in `backend/src/core/auth/security.py`
|
- [x] T037 [US1] Implement password complexity validation logic in `backend/src/core/auth/security.py`
|
||||||
|
|
||||||
## Phase 3: User Story 2 - Plugin-Based Access Control (Priority: P1)
|
## Phase 3: User Story 2 - Plugin-Based Access Control (Priority: P1)
|
||||||
|
|
||||||
*Goal: Restrict access to plugins based on user roles and permissions.*
|
*Goal: Restrict access to plugins based on user roles and permissions.*
|
||||||
|
|
||||||
- [ ] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py`
|
- [x] T019 [US2] Update `PluginBase` to include required permission strings in `backend/src/core/plugin_base.py`
|
||||||
- [ ] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py`
|
- [x] T020 [US2] Implement `has_permission` dependency for route protection in `backend/src/dependencies.py`
|
||||||
- [ ] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
|
- [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
|
||||||
- [ ] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
|
- [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
|
||||||
- [ ] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` (with pagination) in `backend/src/api/routes/admin.py`
|
- [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py`
|
||||||
- [ ] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
|
- [ ] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py`
|
||||||
- [ ] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
|
- [ ] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py`
|
||||||
- [ ] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
|
- [x] T051 [US2] Implement `adminService.js` for frontend API orchestration
|
||||||
- [ ] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`
|
- [ ] T055 [US2] Update `adminService.js` with new CRUD methods
|
||||||
|
- [x] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
|
||||||
|
- [ ] T056 [US2] Update Admin User Dashboard to support Edit/Delete operations in `frontend/src/routes/admin/users/+page.svelte`
|
||||||
|
- [ ] T057 [US4] Create Role Management UI in `frontend/src/routes/admin/roles/+page.svelte`
|
||||||
|
- [x] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
|
||||||
|
- [x] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
|
||||||
|
- [x] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`
|
||||||
|
|
||||||
## Phase 4: User Story 3 - ADFS Integration (Priority: P2)
|
## Phase 4: User Story 3 - ADFS Integration (Priority: P2)
|
||||||
|
|
||||||
*Goal: Enable corporate SSO login via ADFS and JIT provisioning.*
|
*Goal: Enable corporate SSO login via ADFS and JIT provisioning.*
|
||||||
|
|
||||||
- [ ] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py`
|
- [x] T026 [US3] Configure Authlib for ADFS OIDC in `backend/src/core/auth/oauth.py`
|
||||||
- [ ] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script
|
- [x] T027 [US3] Create `ADGroupMapping` model in `backend/src/models/auth.py` and update DB init script
|
||||||
- [ ] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py`
|
- [x] T028 [US3] Implement JIT provisioning logic (create user if maps to group) in `backend/src/services/auth_service.py`
|
||||||
- [ ] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py`
|
- [x] T029 [US3] Create API endpoints `GET /api/auth/login/adfs` and `GET /api/auth/callback/adfs` in `backend/src/api/auth.py`
|
||||||
- [ ] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte`
|
- [x] T030 [US3] Update Login Page to include "Login with ADFS" button using `src/lib/ui` in `frontend/src/routes/login/+page.svelte`
|
||||||
- [ ] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte`
|
- [x] T031 [US3] Implement Admin UI for configuring AD Group Mappings in `frontend/src/routes/admin/settings/+page.svelte`
|
||||||
- [ ] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py`
|
- [x] T052 [US3] Extend Admin API with AD mapping endpoints in `backend/src/api/routes/admin.py`
|
||||||
- [ ] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py`
|
- [x] T041 [US3] Create ADFS mock provider for local testing and CI in `backend/tests/auth/mock_adfs.py`
|
||||||
|
- [x] T046 [US3] Implement token refresh logic for ADFS OIDC tokens in `backend/src/core/auth/jwt.py`
|
||||||
|
|
||||||
## Phase 5: Polish & Security Hardening
|
## Phase 5: Polish & Security Hardening
|
||||||
|
|
||||||
*Goal: Ensure security best practices and smooth UX.*
|
*Goal: Ensure security best practices and smooth UX.*
|
||||||
|
|
||||||
- [ ] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py`
|
- [x] T032 Ensure all cookies are set with `HttpOnly` and `Secure` flags in `backend/src/api/auth.py`
|
||||||
- [ ] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py`
|
- [x] T033 Implement rate limiting and account lockout policy in `backend/src/api/auth.py`
|
||||||
- [ ] T034 Verify error messages are generic (no username enumeration) across all auth endpoints
|
- [x] T034 Verify error messages are generic (no username enumeration) across all auth endpoints
|
||||||
- [ ] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts`
|
- [x] T035 Add "Session Expired" handling in frontend interceptor in `frontend/src/lib/api/client.ts`
|
||||||
- [ ] T036 Final manual test of switching between Local and ADFS login flows
|
- [x] T036 Final manual test of switching between Local and ADFS login flows
|
||||||
- [ ] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte`
|
- [x] T040 Add confirmation dialogs for destructive admin actions using `src/lib/ui` in `frontend/src/routes/admin/users/+page.svelte`
|
||||||
- [ ] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py`
|
- [x] T047 Implement audit logging for security events (login, logout, permission changes) in `backend/src/core/auth/logger.py`
|
||||||
- [ ] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components
|
- [x] T048 Perform UI accessibility audit (keyboard nav, ARIA alerts) for all auth components
|
||||||
- [ ] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/`
|
- [x] T049 Implement unit and integration tests for Local Auth and RBAC in `backend/tests/auth/`
|
||||||
- [ ] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
|
- [x] T050 Implement E2E tests for ADFS flow using mock provider in `tests/e2e/auth.spec.ts`
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user